@eventmodelers/cli 0.0.37 → 0.0.38
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/cli.js +227 -8
- package/package.json +1 -1
package/cli.js
CHANGED
|
@@ -281,19 +281,28 @@ function maskSecret(value) {
|
|
|
281
281
|
// scripted installs) — the first interface can read ahead and consume lines meant
|
|
282
282
|
// for later prompts, leaving the next one waiting on a stream that already ended.
|
|
283
283
|
let sharedRl = null;
|
|
284
|
+
let sharedRlLines = null;
|
|
284
285
|
function getSharedRl() {
|
|
285
286
|
if (!sharedRl) {
|
|
286
287
|
sharedRl = createInterface({ input: process.stdin, output: process.stdout });
|
|
288
|
+
sharedRlLines = sharedRl[Symbol.asyncIterator]();
|
|
287
289
|
}
|
|
288
290
|
return sharedRl;
|
|
289
291
|
}
|
|
290
292
|
|
|
291
|
-
async
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
293
|
+
// Pulls one line from the shared readline's own async iterator rather than calling
|
|
294
|
+
// its `.question()` — `.question()` attaches a one-shot 'line' listener *after* the
|
|
295
|
+
// prompt is issued, but when stdin is piped (a file, `<<<`, scripted/CI input) readline
|
|
296
|
+
// parses and emits 'line' events for an entire buffered chunk synchronously as soon as
|
|
297
|
+
// it arrives. So a second `.question()` call in the same process can miss a line that
|
|
298
|
+
// was already emitted — and dropped, no listener attached yet — before it was even
|
|
299
|
+
// called, hanging forever. Pulling from the iterator instead queues each line until
|
|
300
|
+
// something asks for it, so nothing emitted ahead of time is ever lost between prompts.
|
|
301
|
+
async function prompt(question = '') {
|
|
302
|
+
getSharedRl();
|
|
303
|
+
if (question) process.stdout.write(question);
|
|
304
|
+
const { value, done } = await sharedRlLines.next();
|
|
305
|
+
return (done ? '' : value).trim();
|
|
297
306
|
}
|
|
298
307
|
|
|
299
308
|
// Reads a pasted block of credentials, which may span one line (minified JSON,
|
|
@@ -303,7 +312,7 @@ async function prompt(question) {
|
|
|
303
312
|
async function promptPasteBlock() {
|
|
304
313
|
const lines = [];
|
|
305
314
|
while (lines.length < 20) {
|
|
306
|
-
const line = await
|
|
315
|
+
const line = await prompt();
|
|
307
316
|
if (line.trim() === '') {
|
|
308
317
|
if (lines.length > 0) break;
|
|
309
318
|
continue;
|
|
@@ -1352,7 +1361,11 @@ program
|
|
|
1352
1361
|
// cleanup commands that are meant to work — and report something useful — whether
|
|
1353
1362
|
// or not a kit is present, and fetch only needs credentials plus somewhere to write
|
|
1354
1363
|
// .slices/ (cwd, absent a kit dir — see lib/fetch.js), no kit-specific files.
|
|
1355
|
-
|
|
1364
|
+
// activate-context/set-slice-status are the same story minus even the credentials — they
|
|
1365
|
+
// only ever read/write an already-fetched .slices/, and report their own hint (run `fetch`
|
|
1366
|
+
// first) when that's missing. set-slice-status only touches credentials at all for --remote,
|
|
1367
|
+
// which prompts for them itself the same way fetch does.
|
|
1368
|
+
const NO_INIT_REQUIRED = new Set(['init', 'init-config', 'stacks', 'status', 'config', 'uninstall', 'fetch', 'activate-context', 'set-slice-status']);
|
|
1356
1369
|
|
|
1357
1370
|
program.hook('preAction', (_thisCommand, actionCommand) => {
|
|
1358
1371
|
if (NO_INIT_REQUIRED.has(actionCommand.name())) return;
|
|
@@ -1818,6 +1831,212 @@ program
|
|
|
1818
1831
|
}
|
|
1819
1832
|
});
|
|
1820
1833
|
|
|
1834
|
+
program
|
|
1835
|
+
.command('activate-context')
|
|
1836
|
+
.description('Choose which fetched context is active — writes .slices/current_context.json, which `run`/bridge/listen treat as sticky and never cross out of on their own')
|
|
1837
|
+
.action(async () => {
|
|
1838
|
+
const cwd = process.cwd();
|
|
1839
|
+
const kitDir = findInstalledKitDir(cwd);
|
|
1840
|
+
// Same modeling-kit exception `fetch` applies — see its action for why.
|
|
1841
|
+
const slicesKitDir = kitDir?.endsWith(MODELING_KIT.kitDirName) ? null : kitDir;
|
|
1842
|
+
const SLICES_DIR = join(slicesKitDir || cwd, '.slices');
|
|
1843
|
+
const hint = ' Run `eventmodelers fetch --context <name>` first to pull a context from the board.';
|
|
1844
|
+
|
|
1845
|
+
// A context is any .slices/ subdirectory fetch/listen wrote an index.json into —
|
|
1846
|
+
// that's the file both of them use as proof a context's slices actually landed.
|
|
1847
|
+
const contextDirs = existsSync(SLICES_DIR)
|
|
1848
|
+
? readdirSync(SLICES_DIR, { withFileTypes: true })
|
|
1849
|
+
.filter((e) => e.isDirectory() && existsSync(join(SLICES_DIR, e.name, 'index.json')))
|
|
1850
|
+
.map((e) => e.name)
|
|
1851
|
+
: [];
|
|
1852
|
+
|
|
1853
|
+
if (!contextDirs.length) {
|
|
1854
|
+
console.error(`❌ No contexts found in ${relative(cwd, SLICES_DIR)}/.`);
|
|
1855
|
+
console.error(hint);
|
|
1856
|
+
process.exit(1);
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
const currentCtx = readJsonSafe(join(SLICES_DIR, 'current_context.json')).name;
|
|
1860
|
+
|
|
1861
|
+
// context.json's `name` is the human-readable display name; the directory itself
|
|
1862
|
+
// (contextSlug) is what current_context.json must store, since readCurrentContext
|
|
1863
|
+
// (shared/build-kit/lib/ralph.js) joins it straight onto `.slices/<name>/index.json`.
|
|
1864
|
+
const choices = contextDirs.map((dirName) => ({
|
|
1865
|
+
label: readJsonSafe(join(SLICES_DIR, dirName, 'context.json')).name || dirName,
|
|
1866
|
+
value: dirName,
|
|
1867
|
+
}));
|
|
1868
|
+
const defaultIndex = Math.max(0, contextDirs.indexOf(currentCtx));
|
|
1869
|
+
|
|
1870
|
+
const selected = await selectPrompt(
|
|
1871
|
+
`Which context should be active?${currentCtx ? ` (currently: ${choices[defaultIndex].label})` : ''}`,
|
|
1872
|
+
choices,
|
|
1873
|
+
defaultIndex,
|
|
1874
|
+
);
|
|
1875
|
+
|
|
1876
|
+
writeFileSync(join(SLICES_DIR, 'current_context.json'), JSON.stringify({ name: selected }, null, 2));
|
|
1877
|
+
const selectedLabel = choices.find((c) => c.value === selected)?.label || selected;
|
|
1878
|
+
console.log(`✅ Active context set to "${selectedLabel}" → ${relative(cwd, join(SLICES_DIR, 'current_context.json'))}`);
|
|
1879
|
+
});
|
|
1880
|
+
|
|
1881
|
+
// Order/icons/default mirror the board UI's own slice-status picker.
|
|
1882
|
+
const SLICE_STATUSES = [
|
|
1883
|
+
{ label: '🌱 Created (default)', value: 'Created' },
|
|
1884
|
+
{ label: '✅ Done', value: 'Done' },
|
|
1885
|
+
{ label: '👤 Assigned', value: 'Assigned' },
|
|
1886
|
+
{ label: '🔄 InProgress', value: 'InProgress' },
|
|
1887
|
+
{ label: '🔍 Review', value: 'Review' },
|
|
1888
|
+
{ label: '🚫 Blocked', value: 'Blocked' },
|
|
1889
|
+
{ label: '📅 Planned', value: 'Planned' },
|
|
1890
|
+
{ label: 'ℹ️ Informational', value: 'Informational' },
|
|
1891
|
+
];
|
|
1892
|
+
|
|
1893
|
+
program
|
|
1894
|
+
.command('set-slice-status')
|
|
1895
|
+
.description('Pick a slice from the active context and change its status — updates .slices/ locally, and the board itself with --remote')
|
|
1896
|
+
.option('--remote', 'Also push the change to the board via the nodes/events API (same effect `update-slice-status` has, see shared/skills/update-slice-status)')
|
|
1897
|
+
.action(async (opts, command) => {
|
|
1898
|
+
const cwd = process.cwd();
|
|
1899
|
+
const kitDir = findInstalledKitDir(cwd);
|
|
1900
|
+
// Same modeling-kit exception `fetch`/`activate-context` apply — see fetch's action for why.
|
|
1901
|
+
const slicesKitDir = kitDir?.endsWith(MODELING_KIT.kitDirName) ? null : kitDir;
|
|
1902
|
+
const SLICES_DIR = join(slicesKitDir || cwd, '.slices');
|
|
1903
|
+
const fetchHint = ' Run `eventmodelers fetch --context <name>` first to pull a context from the board.';
|
|
1904
|
+
|
|
1905
|
+
const currentCtx = readJsonSafe(join(SLICES_DIR, 'current_context.json')).name;
|
|
1906
|
+
if (!currentCtx) {
|
|
1907
|
+
console.error('❌ No active context set.');
|
|
1908
|
+
console.error(existsSync(SLICES_DIR) ? ' Run `eventmodelers activate-context` to pick one.' : fetchHint);
|
|
1909
|
+
process.exit(1);
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
const contextDir = join(SLICES_DIR, currentCtx);
|
|
1913
|
+
const indexPath = join(contextDir, 'index.json');
|
|
1914
|
+
const indexData = readJsonSafe(indexPath);
|
|
1915
|
+
const slices = Array.isArray(indexData.slices) ? indexData.slices : [];
|
|
1916
|
+
if (!slices.length) {
|
|
1917
|
+
console.error(`❌ No slices found for context "${currentCtx}".`);
|
|
1918
|
+
console.error(fetchHint);
|
|
1919
|
+
process.exit(1);
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
const sliceChoices = slices.map((s) => ({ label: `${s.slice || s.id} [${s.status || 'Created'}]`, value: s.id }));
|
|
1923
|
+
const sliceId = await selectPrompt(`Which slice in "${currentCtx}" should change status?`, sliceChoices, 0);
|
|
1924
|
+
const slice = slices.find((s) => s.id === sliceId);
|
|
1925
|
+
|
|
1926
|
+
const statusDefault = Math.max(0, SLICE_STATUSES.findIndex((s) => s.value === (slice.status || 'Created')));
|
|
1927
|
+
const newStatus = await selectPrompt(`New status for "${slice.slice}"? (currently: ${slice.status || 'Created'})`, SLICE_STATUSES, statusDefault);
|
|
1928
|
+
|
|
1929
|
+
if (newStatus === (slice.status || 'Created')) {
|
|
1930
|
+
console.log(`ℹ️ "${slice.slice}" is already ${newStatus} — nothing to change.`);
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
// index.json's `definition` is a full copy of the slice (see lib/fetch.js's entry
|
|
1935
|
+
// shape) — keep both copies of `status` in sync so anything reading either stays correct.
|
|
1936
|
+
const previousStatus = slice.status || 'Created';
|
|
1937
|
+
slice.status = newStatus;
|
|
1938
|
+
if (slice.definition) slice.definition.status = newStatus;
|
|
1939
|
+
writeFileSync(indexPath, JSON.stringify(indexData, null, 2));
|
|
1940
|
+
|
|
1941
|
+
if (slice.folder) {
|
|
1942
|
+
const sliceJsonPath = join(contextDir, slice.folder, 'slice.json');
|
|
1943
|
+
if (existsSync(sliceJsonPath)) {
|
|
1944
|
+
const sliceData = readJsonSafe(sliceJsonPath);
|
|
1945
|
+
sliceData.status = newStatus;
|
|
1946
|
+
writeFileSync(sliceJsonPath, JSON.stringify(sliceData, null, 2));
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
console.log(`✅ "${slice.slice}": ${previousStatus} → ${newStatus} (${relative(cwd, indexPath)})`);
|
|
1951
|
+
|
|
1952
|
+
if (!opts.remote) return;
|
|
1953
|
+
|
|
1954
|
+
// slice.id is the SLICE_BORDER node ID (see shared/skills/update-slice-status/SKILL.md
|
|
1955
|
+
// Step 2) — the same id the board's own nodes/events API expects as nodeId below.
|
|
1956
|
+
const globalOpts = command.optsWithGlobals();
|
|
1957
|
+
const explicitConfig = globalOpts.config;
|
|
1958
|
+
const configPath = explicitConfig ? resolve(cwd, explicitConfig) : join(cwd, '.eventmodelers', 'config.json');
|
|
1959
|
+
const requiredFields = ['organizationId', 'boardId', 'token'];
|
|
1960
|
+
let { config: cfg } = loadEffectiveConfig(cwd, kitDir, explicitConfig);
|
|
1961
|
+
|
|
1962
|
+
async function promptForCredentials() {
|
|
1963
|
+
cfg = await configureCredentials({
|
|
1964
|
+
config: cfg,
|
|
1965
|
+
configPath,
|
|
1966
|
+
targetDir: cwd,
|
|
1967
|
+
requiredFields,
|
|
1968
|
+
boardIdOptional: false,
|
|
1969
|
+
overrides: {},
|
|
1970
|
+
print: globalOpts.print,
|
|
1971
|
+
});
|
|
1972
|
+
if (requiredFields.some((f) => !cfg[f])) {
|
|
1973
|
+
console.error('❌ Still missing token/organizationId/boardId — re-run with --remote once configured.');
|
|
1974
|
+
process.exit(1);
|
|
1975
|
+
}
|
|
1976
|
+
}
|
|
1977
|
+
if (requiredFields.some((f) => !cfg[f])) await promptForCredentials();
|
|
1978
|
+
|
|
1979
|
+
const baseUrl = cfg.baseUrl || DEFAULT_BASE_URL;
|
|
1980
|
+
async function pushRemote() {
|
|
1981
|
+
return fetch(`${baseUrl}/api/org/${cfg.organizationId}/boards/${cfg.boardId}/nodes/events`, {
|
|
1982
|
+
method: 'POST',
|
|
1983
|
+
headers: {
|
|
1984
|
+
'Content-Type': 'application/json',
|
|
1985
|
+
'x-token': cfg.token,
|
|
1986
|
+
'x-board-id': cfg.boardId,
|
|
1987
|
+
'x-user-id': 'cli-set-slice-status',
|
|
1988
|
+
},
|
|
1989
|
+
body: JSON.stringify([{
|
|
1990
|
+
id: randomUUID(),
|
|
1991
|
+
eventType: 'node:changed',
|
|
1992
|
+
nodeId: slice.id,
|
|
1993
|
+
boardId: cfg.boardId,
|
|
1994
|
+
timestamp: Date.now(),
|
|
1995
|
+
changedAttributes: ['sliceStatus'],
|
|
1996
|
+
meta: { sliceStatus: newStatus },
|
|
1997
|
+
}]),
|
|
1998
|
+
});
|
|
1999
|
+
}
|
|
2000
|
+
|
|
2001
|
+
let res;
|
|
2002
|
+
try {
|
|
2003
|
+
res = await pushRemote();
|
|
2004
|
+
} catch (err) {
|
|
2005
|
+
console.error(`❌ Remote update failed: ${err.message}`);
|
|
2006
|
+
process.exit(1);
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
// Same 401/403 reconfigure-and-retry dance `fetch` does — present-but-wrong
|
|
2010
|
+
// credentials, not missing ones, so clear whichever field is implicated and retry once.
|
|
2011
|
+
if (res.status === 401 || res.status === 403) {
|
|
2012
|
+
console.error(`❌ Remote update: ${res.status === 401 ? 'invalid or expired token' : "token's organization does not match this board"}.`);
|
|
2013
|
+
if (res.status === 403) delete cfg.boardId; else delete cfg.token;
|
|
2014
|
+
await promptForCredentials();
|
|
2015
|
+
try {
|
|
2016
|
+
res = await pushRemote();
|
|
2017
|
+
} catch (err) {
|
|
2018
|
+
console.error(`❌ Remote update failed: ${err.message}`);
|
|
2019
|
+
process.exit(1);
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
|
|
2023
|
+
if (!res.ok) {
|
|
2024
|
+
const body = await res.json().catch(() => null);
|
|
2025
|
+
const msg = body?.error || `HTTP ${res.status}`;
|
|
2026
|
+
// The API refuses to move a slice into a status it's already in (a concurrency
|
|
2027
|
+
// guard so two agents/users can't both claim it) — not a real failure, just means
|
|
2028
|
+
// the board had already moved on since the last fetch.
|
|
2029
|
+
if (/already/i.test(msg)) {
|
|
2030
|
+
console.log(`ℹ️ Board already has "${slice.slice}" at ${newStatus} — no remote change needed.`);
|
|
2031
|
+
return;
|
|
2032
|
+
}
|
|
2033
|
+
console.error(`❌ Remote update failed: ${msg}`);
|
|
2034
|
+
process.exit(1);
|
|
2035
|
+
}
|
|
2036
|
+
|
|
2037
|
+
console.log(`✅ Pushed status change to the board (node ${slice.id}).`);
|
|
2038
|
+
});
|
|
2039
|
+
|
|
1821
2040
|
program
|
|
1822
2041
|
.command('stacks')
|
|
1823
2042
|
.description('List available stacks (for `init --stack`)')
|
package/package.json
CHANGED