@maka/maka-cli 5.161.0 → 5.163.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/bundle/typescript/package.json +1 -1
- package/bundle/typescript/src/commands/game/sideQuest/commands/backlog.js +5 -0
- package/bundle/typescript/src/commands/game/sideQuest/engine-version.js +5 -1
- package/bundle/typescript/src/commands/game/sideQuest/game.js +14 -1
- package/bundle/typescript/src/commands/game/sideQuest/headless.js +14 -3
- package/bundle/typescript/src/commands/game/sideQuest/utilities/condition-report.js +60 -8
- package/bundle/typescript/src/commands/game/sideQuest/utilities/map-view.js +33 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/room-grid.js +10 -0
- package/bundle/typescript/src/commands/game/sideQuest/utilities/shared-run.js +23 -0
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.163.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
|
|
6
6
|
"description": "A command line tool for scaffolding Meteor 3.x applications using React.",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Command } from './command.js';
|
|
2
2
|
import { hint } from '../utilities/hints.js';
|
|
3
3
|
import { authToken } from '../utilities/cloud-saves.js';
|
|
4
|
+
import { currentSession } from '../utilities/session-context.js';
|
|
4
5
|
import { submitBacklog, fetchBacklog, postBacklogComment, postBacklogSignoff, postBacklogReject, postBacklogClose, BACKLOG_STATUSES, } from '../utilities/backlog.js';
|
|
5
6
|
import { noteBacklogOutcome } from '../utilities/playtester.js';
|
|
6
7
|
import { takeIndex } from '../utilities/fuzzy-match.js';
|
|
@@ -163,6 +164,10 @@ export class BacklogCommand extends Command {
|
|
|
163
164
|
cliVersion: cliVersion(),
|
|
164
165
|
engineVersion: engineVersion(),
|
|
165
166
|
platform: process.platform,
|
|
167
|
+
// The host names the client (session-context.ts): a browser run's
|
|
168
|
+
// engine is the same code on a linux box, and 'linux' told nobody
|
|
169
|
+
// where the report actually came from (wQEyefjzbwtdmdrJo).
|
|
170
|
+
client: currentSession()?.client ?? 'cli',
|
|
166
171
|
});
|
|
167
172
|
// A real call classifies the account for free -- no second request.
|
|
168
173
|
noteBacklogOutcome(outcome);
|
|
@@ -392,5 +392,9 @@
|
|
|
392
392
|
// aggressor and their target; the world event carries it; "initiative"
|
|
393
393
|
// marks them. Hostiles are red on the room map by stance or by live
|
|
394
394
|
// enmity, not by the retired exchange flag.
|
|
395
|
-
|
|
395
|
+
// 1.45.0 (2026-09-11): THE MAPS AS DATA. The condition beat carries the
|
|
396
|
+
// room's live grid, every body's engine-resolved square, and the room
|
|
397
|
+
// panel's own hostile test; the map event carries the fog-applied
|
|
398
|
+
// district layout (data.district). Envelope and beat shape both grew.
|
|
399
|
+
export const ENGINE_VERSION = '1.45.0';
|
|
396
400
|
//# sourceMappingURL=engine-version.js.map
|
|
@@ -2401,7 +2401,11 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
2401
2401
|
if (!this._conditionSink && process.env.MAKA_NO_DDP === '1')
|
|
2402
2402
|
return;
|
|
2403
2403
|
void import('./utilities/condition-report.js')
|
|
2404
|
-
.then(m => m.reportConditionSoon(this.player, this._conditionSink
|
|
2404
|
+
.then(m => m.reportConditionSoon(this.player, this._conditionSink, {
|
|
2405
|
+
// The room panel's own red (updateExits below): stance OR live
|
|
2406
|
+
// enmity in the viewer's encounter. Same predicate, same map.
|
|
2407
|
+
isHostile: a => hostileByStance(a) || enemyInFight(this.scene, this.player, a),
|
|
2408
|
+
}))
|
|
2405
2409
|
.catch(() => undefined);
|
|
2406
2410
|
};
|
|
2407
2411
|
// A ROOM CHANGE IS A SAVE BEAT (player request 2026-09-02): where you
|
|
@@ -5303,6 +5307,15 @@ ${client.name} won't be remembering anything. Dead Johnsons pay nothing -- and t
|
|
|
5303
5307
|
const heat = this.heatBand();
|
|
5304
5308
|
if (heat && player === this.player)
|
|
5305
5309
|
lines.push(heat);
|
|
5310
|
+
// THE ALARM (kqrSCWjj96SP4MRXj, web run 2026-09-11: "I got a
|
|
5311
|
+
// notification that the alarms sound, but I don't see any
|
|
5312
|
+
// indication in my status bar"). The site going loud was a world
|
|
5313
|
+
// event that scrolled away; Scene.alarmRaised has been the standing
|
|
5314
|
+
// fact since 2026-08-27, and it belongs on the HUD for as long as it
|
|
5315
|
+
// holds. A fact about the PLACE, so every player at the table sees
|
|
5316
|
+
// it -- unlike heat, which is the primary's own ledger.
|
|
5317
|
+
if (this.scene?.alarmRaised)
|
|
5318
|
+
lines.push(`{red-fg}🚨 ALARM -- the site is lit{/red-fg}`);
|
|
5306
5319
|
// Line 3: the rest of the readiness read -- armor, and whether the
|
|
5307
5320
|
// commlink is actually live (equipped), the recurring "why can't I
|
|
5308
5321
|
// call anyone" friction surfaced at a glance. The armed state used
|
|
@@ -17,7 +17,7 @@ import { Logger } from './utilities/logger.js';
|
|
|
17
17
|
import { currentSession, runInSession } from './utilities/session-context.js';
|
|
18
18
|
import { restorePlayer, serializePlayer } from './utilities/persistence.js';
|
|
19
19
|
import { runWithAuthToken } from './utilities/cloud-saves.js';
|
|
20
|
-
import { renderMapViewport, mapCaption } from './utilities/map-view.js';
|
|
20
|
+
import { renderMapViewport, mapCaption, serializeDistrictMap } from './utilities/map-view.js';
|
|
21
21
|
import { hostileByStance, enemyInFight, renderRoomLayoutCompact, crewOfTable, roomCaption } from './utilities/room-view.js';
|
|
22
22
|
import { serializeRoomGrid } from './utilities/room-grid.js';
|
|
23
23
|
import { entrySpotFor, spotOf } from './utilities/spots.js';
|
|
@@ -190,7 +190,7 @@ export function buildPlayerFromSave(save) {
|
|
|
190
190
|
}
|
|
191
191
|
export async function createHeadlessSession(opts) {
|
|
192
192
|
const logger = new EmitterLogger(opts.sink, opts.debug);
|
|
193
|
-
const ctx = { sessionId: opts.sessionId, logger };
|
|
193
|
+
const ctx = { sessionId: opts.sessionId, logger, client: opts.client };
|
|
194
194
|
return runInSession(ctx, async () => {
|
|
195
195
|
const primary = buildPlayerFromSave(opts.players[0]);
|
|
196
196
|
const game = new Game(primary, opts.seed, opts.conditionSink);
|
|
@@ -297,7 +297,18 @@ export async function createHeadlessSession(opts) {
|
|
|
297
297
|
const mapLines = renderMapViewport(rooms, here, sealing, eyes);
|
|
298
298
|
if (mapLines.length > 0)
|
|
299
299
|
mapLines.push(mapCaption(here.name));
|
|
300
|
-
|
|
300
|
+
// STRUCTURED DISTRICT MAP, ADDITIVE (2026-09-11): the same layout
|
|
301
|
+
// as data, on this same event, so the browser client draws it in
|
|
302
|
+
// its own style instead of reparsing the text (the `data.grid`
|
|
303
|
+
// pattern below). A decoration must never kill the push.
|
|
304
|
+
let district;
|
|
305
|
+
try {
|
|
306
|
+
district = serializeDistrictMap(rooms, here, sealing, eyes);
|
|
307
|
+
}
|
|
308
|
+
catch (err) {
|
|
309
|
+
logger.write(`District map serialize failed for ${p.name} in ${here.name}: ${err instanceof Error ? err.stack ?? err.message : String(err)}`);
|
|
310
|
+
}
|
|
311
|
+
logger.system(mapLines.join('\n'), { actor: p.name }, 'map', district ? { district } : undefined);
|
|
301
312
|
// THE ROOM BOX'S TWIN (playtest 2026-08-27, reported twice:
|
|
302
313
|
// "the room minimap isn't updating in the shared run, I only
|
|
303
314
|
// see @open floor"). Game.updateExits early-returns on
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { Logger } from './logger.js';
|
|
2
|
-
import { visibleActorsIn } from './spots.js';
|
|
2
|
+
import { visibleActorsIn, seatingIn } from './spots.js';
|
|
3
3
|
import { hostLabel } from './grid-names.js';
|
|
4
|
+
import { serializeRoomGrid } from './room-grid.js';
|
|
5
|
+
import { hostileByStance } from './room-view.js';
|
|
4
6
|
/**
|
|
5
7
|
* THE LIVE CONDITION EMITTER -- the CLI half of the realtime sheet lane
|
|
6
8
|
* (contract agreed with maka-cli.com, 2026-09-01). On every condition
|
|
@@ -71,13 +73,56 @@ export function setMarksPlacedResolver(fn) {
|
|
|
71
73
|
export function setActorRoleResolver(fn) {
|
|
72
74
|
actorRole = fn;
|
|
73
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* THE ROOM'S GEOMETRY, ON THE BEAT (2026-09-11, 8bZXzNXLgKwE5wxgh: "no
|
|
78
|
+
* icons being drawn on the character sheet in the active run"). The web
|
|
79
|
+
* sheet rebuilt its room from the SAVE's hub seed, and a generated run
|
|
80
|
+
* room is not in it -- so in a run the sheet had no furniture, no doors,
|
|
81
|
+
* and no cell to seat a spot-sitting NPC on. The beat already fires on
|
|
82
|
+
* every position change and is the one channel both the sheet and the
|
|
83
|
+
* play page read, so the live grid rides it: the SAME wire shape the
|
|
84
|
+
* room event carries for the browser client (headless.ts pushMap ->
|
|
85
|
+
* serializeRoomGrid). Physical planes only -- jacked in, the meat map is
|
|
86
|
+
* a lie the sheet already refuses to draw. A decoration never kills the
|
|
87
|
+
* beat: any throw here is an absent field, not a lost report.
|
|
88
|
+
*/
|
|
89
|
+
function roomGridFor(player) {
|
|
90
|
+
const room = player.currentLocation;
|
|
91
|
+
if (!room || (player.plane !== 'meat' && player.plane !== 'drone'))
|
|
92
|
+
return undefined;
|
|
93
|
+
try {
|
|
94
|
+
const grid = room.ensureGrid();
|
|
95
|
+
return grid ? serializeRoomGrid(grid, room.name) : undefined;
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
74
101
|
export function buildConditionReport(player, deps) {
|
|
75
|
-
const { saveSlug, arSightUp } = deps;
|
|
102
|
+
const { saveSlug, arSightUp, isHostile } = deps;
|
|
76
103
|
// BRICKED INCLUDED (web report 2026-09-02: "my deck got bricked, but its
|
|
77
104
|
// status still says 4/12"): a forced dump clears activeDeck and
|
|
78
105
|
// getCyberdeck() hides a bricked deck, so the beat read nothing and the
|
|
79
106
|
// sheet froze on the last number. Status reads getAnyCyberdeck.
|
|
80
107
|
const deck = player.activeDeck ?? player.getAnyCyberdeck();
|
|
108
|
+
// WHERE EVERYONE ACTUALLY STANDS (2026-09-11, user: "movement isn't
|
|
109
|
+
// snappy" on the web). The beat sent `atCell` alone, and `atCell` is
|
|
110
|
+
// the EXCEPTION -- set by a tactical step, cleared by "move to <spot>"
|
|
111
|
+
// and by most arrivals -- so after a plain move the beat carried no
|
|
112
|
+
// cell, the server kept the last one it had, and the dot on the web
|
|
113
|
+
// sat still at a square the runner had left. seatingIn is the engine's
|
|
114
|
+
// one answer to "which square" (the room panel, cover and ranges all
|
|
115
|
+
// read it), so the web now gets the same square the CLI draws. Read at
|
|
116
|
+
// fire time like everything else here; a throw means "no seat", never
|
|
117
|
+
// a lost beat.
|
|
118
|
+
const room = player.currentLocation;
|
|
119
|
+
let seats = new Map();
|
|
120
|
+
if (room && (player.plane === 'meat' || player.plane === 'drone')) {
|
|
121
|
+
try {
|
|
122
|
+
seats = seatingIn(room);
|
|
123
|
+
}
|
|
124
|
+
catch { /* keep the empty map */ }
|
|
125
|
+
}
|
|
81
126
|
return {
|
|
82
127
|
slug: saveSlug(player.name),
|
|
83
128
|
damageTaken: player.damageTaken,
|
|
@@ -85,7 +130,8 @@ export function buildConditionReport(player, deps) {
|
|
|
85
130
|
overflow: 0,
|
|
86
131
|
room: player.currentLocation?.name,
|
|
87
132
|
spot: player.atSpot,
|
|
88
|
-
cell: player.atCell,
|
|
133
|
+
cell: player.atCell ?? seats.get(player.name),
|
|
134
|
+
grid: roomGridFor(player),
|
|
89
135
|
plane: player.plane,
|
|
90
136
|
// Where the meat sits while the persona rides the grid (web accepts
|
|
91
137
|
// since 2026-09-02): "jacked in from Your Hideout". Absent in the meat.
|
|
@@ -196,11 +242,14 @@ export function buildConditionReport(player, deps) {
|
|
|
196
242
|
.slice(0, 24)
|
|
197
243
|
.map(a => ({
|
|
198
244
|
name: a.name,
|
|
245
|
+
// THE SAME RED THE ROOM PANEL USES (see IConditionReportDeps
|
|
246
|
+
// .isHostile): stance flag OR live enmity, and never a
|
|
247
|
+
// surrendered one -- hostileByStance owns that clause.
|
|
199
248
|
kind: a.allyOf ? 'ally'
|
|
200
|
-
: a
|
|
249
|
+
: (isHostile?.(a) ?? hostileByStance(a)) ? 'hostile' : 'neutral',
|
|
201
250
|
role: actorRole(a),
|
|
202
251
|
spot: a.atSpot,
|
|
203
|
-
cell: a.atCell,
|
|
252
|
+
cell: a.atCell ?? seats.get(a.name),
|
|
204
253
|
})),
|
|
205
254
|
};
|
|
206
255
|
}
|
|
@@ -227,7 +276,7 @@ function noteRoster(report) {
|
|
|
227
276
|
const ADDITIVE_FIELDS = [
|
|
228
277
|
'ar', 'actors', 'deckLine', 'deckDamage', 'astralPerceiving', 'bodyRoom', 'sustainingPenalty',
|
|
229
278
|
'matrix', 'panMarks', 'hostMarksOnYou', 'marksPlaced', 'marksOnYou', 'deckStored', 'droneDamage', 'companions', 'pools', 'initiative',
|
|
230
|
-
'karma', 'nuyen', 'edge',
|
|
279
|
+
'karma', 'nuyen', 'edge', 'grid',
|
|
231
280
|
];
|
|
232
281
|
/** Refusal reasons already written to the session log -- one line each. */
|
|
233
282
|
const announcedRefusals = new Map();
|
|
@@ -242,7 +291,10 @@ function noteRefusal(why) {
|
|
|
242
291
|
catch { /* no session */ }
|
|
243
292
|
}
|
|
244
293
|
}
|
|
245
|
-
export function reportConditionSoon(player, sink
|
|
294
|
+
export function reportConditionSoon(player, sink,
|
|
295
|
+
/** The Scene-side half of the beat (IConditionReportDeps.isHostile);
|
|
296
|
+
* the caller that owns a Scene supplies it, nobody else needs to. */
|
|
297
|
+
extra) {
|
|
246
298
|
// Before the TIMER, not just before the call: a scheduled beat firing
|
|
247
299
|
// after a Jest file finished lazy-imported into a torn-down
|
|
248
300
|
// environment and failed CI with every test green (2026-09-01). Under
|
|
@@ -269,7 +321,7 @@ export function reportConditionSoon(player, sink) {
|
|
|
269
321
|
// stance and brandish flow through their accessors' own reports;
|
|
270
322
|
// sustained and deck programs are composed here and ride whatever
|
|
271
323
|
// beat fires next. All read at FIRE time, like position.
|
|
272
|
-
const report = buildConditionReport(player, { saveSlug, arSightUp });
|
|
324
|
+
const report = buildConditionReport(player, { saveSlug, arSightUp, isHostile: extra?.isHostile });
|
|
273
325
|
noteRoster(report);
|
|
274
326
|
if (sink) {
|
|
275
327
|
// IN-PROCESS: no HubLink, no MAKA_NO_DDP gate, no version-skew
|
|
@@ -220,6 +220,39 @@ export function renderMapViewport(rooms, here, sealing, eyes, viewW = 21, viewH
|
|
|
220
220
|
out[halfH] = out[halfH].slice(0, halfW) + `{inverse}${out[halfH][halfW] === ' ' ? '▣' : out[halfH][halfW]}{/inverse}` + out[halfH].slice(halfW + 1);
|
|
221
221
|
return out;
|
|
222
222
|
}
|
|
223
|
+
export function serializeDistrictMap(rooms, here, sealing, eyes) {
|
|
224
|
+
const floors = layoutFloors(rooms, here, sealing);
|
|
225
|
+
if (floors.length === 0)
|
|
226
|
+
return undefined;
|
|
227
|
+
const eye = fogEye(rooms, eyes?.visited);
|
|
228
|
+
const ar = eyes?.arEyes !== false;
|
|
229
|
+
const out = floors.map(floor => {
|
|
230
|
+
const linkSeen = (k) => {
|
|
231
|
+
const a = floor.roomAt.get(`${k.ax},${k.ay}`);
|
|
232
|
+
const b = floor.roomAt.get(`${k.bx},${k.by}`);
|
|
233
|
+
return !!a && !!b && eye.sees(a) && eye.sees(b) && (eye.knows(a) || eye.knows(b));
|
|
234
|
+
};
|
|
235
|
+
const seen = [...floor.posByRoom.entries()].filter(([r]) => eye.sees(r));
|
|
236
|
+
return {
|
|
237
|
+
rooms: seen.map(([r, p]) => {
|
|
238
|
+
const known = eye.knows(r);
|
|
239
|
+
return {
|
|
240
|
+
x: p.x, y: p.y,
|
|
241
|
+
...(known ? { name: r.name } : {}),
|
|
242
|
+
known,
|
|
243
|
+
here: r === here,
|
|
244
|
+
node: known && ar && !!r.hasNode,
|
|
245
|
+
up: known && hasWay(r, 'up'),
|
|
246
|
+
down: known && hasWay(r, 'down'),
|
|
247
|
+
};
|
|
248
|
+
}),
|
|
249
|
+
links: floor.links.filter(linkSeen).map(k => ({ ...k })),
|
|
250
|
+
};
|
|
251
|
+
});
|
|
252
|
+
const current = Math.max(0, floors.findIndex(f => f.posByRoom.has(here)));
|
|
253
|
+
const notes = fogNotes(floors[current]?.notes ?? [], rooms, eye);
|
|
254
|
+
return { current, floors: out, notes };
|
|
255
|
+
}
|
|
223
256
|
/**
|
|
224
257
|
* The DETAILED view (the "map" text command): named cells, box-drawing
|
|
225
258
|
* connections, and annotations -- the renderer shipped in 5.48.0, now
|
|
@@ -822,6 +822,14 @@ export function serializeRoomGrid(grid, roomName) {
|
|
|
822
822
|
const [x, y, z] = cellKey.split(',').map(Number);
|
|
823
823
|
return { kind: link.kind, x, y, z };
|
|
824
824
|
});
|
|
825
|
+
const terrain = [...grid.terrain.values()].map(r => ({
|
|
826
|
+
id: r.id, label: r.label, cover: r.cover, blocking: r.blocking, cleared: r.cleared,
|
|
827
|
+
cells: r.cells.map(c => ({ x: c.x, y: c.y, z: c.z })),
|
|
828
|
+
}));
|
|
829
|
+
const levels = [];
|
|
830
|
+
for (let z = 1; z < grid.dims.z; z++) {
|
|
831
|
+
levels.push({ z, cells: levelFootprint(grid, z).map(c => ({ x: c.x, y: c.y })) });
|
|
832
|
+
}
|
|
825
833
|
return {
|
|
826
834
|
name: roomName,
|
|
827
835
|
dims: { x: grid.dims.x, y: grid.dims.y, z: grid.dims.z },
|
|
@@ -829,6 +837,8 @@ export function serializeRoomGrid(grid, roomName) {
|
|
|
829
837
|
doors,
|
|
830
838
|
footprints,
|
|
831
839
|
vertical,
|
|
840
|
+
terrain,
|
|
841
|
+
levels,
|
|
832
842
|
};
|
|
833
843
|
}
|
|
834
844
|
function clamp(n, lo, hi) { return Math.max(lo, Math.min(hi, n)); }
|
|
@@ -1188,6 +1188,29 @@ export class SharedRunSession {
|
|
|
1188
1188
|
}
|
|
1189
1189
|
if (!verb)
|
|
1190
1190
|
return '';
|
|
1191
|
+
// PULLING SOMEBODY INTO A LIVE RUN (user 2026-09-11: "ensure that I
|
|
1192
|
+
// can pull in another runner during a live shared run, and the run
|
|
1193
|
+
// won't collapse"). The crew ledger is hub-only (crew.ts), and the
|
|
1194
|
+
// session's party was frozen at createSession -- so out here "crew
|
|
1195
|
+
// invite" had nowhere to go. The server adds the seat to the OPEN
|
|
1196
|
+
// session (sideQuest.inviteToOpen); their commlink buzzes through
|
|
1197
|
+
// the same invite feed, and their taxi seats them through the same
|
|
1198
|
+
// addPlayer a late joiner always used. Nothing about the scene is
|
|
1199
|
+
// touched: the job was sized at draft time and stays that size.
|
|
1200
|
+
const inviteMatch = /^(?:crew|party)\s+invite\s+(.+)$/i.exec(input.trim());
|
|
1201
|
+
if (inviteMatch) {
|
|
1202
|
+
try {
|
|
1203
|
+
const res = await HubLink.call('sideQuest.inviteToOpen', {
|
|
1204
|
+
sessionID: this.sessionID,
|
|
1205
|
+
query: inviteMatch[1].trim(),
|
|
1206
|
+
});
|
|
1207
|
+
return `Invite sent to ${res.memberRunner} -- a seat at this table, mid-job.${res.online ? '' : ` (They're off the street -- it'll wait on their link.)`}`;
|
|
1208
|
+
}
|
|
1209
|
+
catch (err) {
|
|
1210
|
+
const e = err;
|
|
1211
|
+
return `{yellow-fg}${e.reason ?? e.message ?? 'The offer does not go through.'}{/yellow-fg}`;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1191
1214
|
// THE RIDE HOME: on a shared run the taxi is the settlement
|
|
1192
1215
|
// handshake, not an engine verb -- the leader closes the table for
|
|
1193
1216
|
// everyone (snapshots persist, shares already on each stick); a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@maka/maka-cli",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.163.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"summary": "A command line tool for scaffolding Meteor 3.x applications using either React.",
|
|
6
6
|
"description": "A command line tool for scaffolding Meteor 3.x applications using React.",
|