@astrale-os/cli 1.0.0-beta.13 → 1.0.0-beta.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/astrale.js +107 -23
- package/package.json +1 -1
- package/src/commands/ui/__tests__/commands.test.ts +18 -2
- package/src/program/__tests__/program.test.ts +95 -2
- package/src/program/argv.ts +14 -0
- package/src/program/build.ts +1 -1
- package/src/program/index.ts +1 -0
- package/src/telemetry/__tests__/recorder.test.ts +1 -0
- package/src/telemetry/recorder.ts +9 -1
- package/src/ui/__tests__/ui.test.ts +92 -0
- package/src/ui/project.ts +81 -13
- package/src/ui/release.ts +4 -1
package/dist/astrale.js
CHANGED
|
@@ -2578,7 +2578,7 @@ var package_default;
|
|
|
2578
2578
|
var init_package = __esm(() => {
|
|
2579
2579
|
package_default = {
|
|
2580
2580
|
name: "@astrale-os/cli",
|
|
2581
|
-
version: "1.0.0-beta.
|
|
2581
|
+
version: "1.0.0-beta.14",
|
|
2582
2582
|
description: "Astrale CLI — connect to existing Astrale kernels",
|
|
2583
2583
|
keywords: [
|
|
2584
2584
|
"astrale",
|
|
@@ -83398,11 +83398,43 @@ var init_lock = __esm(() => {
|
|
|
83398
83398
|
});
|
|
83399
83399
|
|
|
83400
83400
|
// src/ui/project.ts
|
|
83401
|
-
import { access as access2, lstat, readFile as readFile17 } from "node:fs/promises";
|
|
83401
|
+
import { access as access2, lstat, readFile as readFile17, realpath as realpath2 } from "node:fs/promises";
|
|
83402
83402
|
import path5 from "node:path";
|
|
83403
83403
|
async function exists(target2) {
|
|
83404
83404
|
return access2(target2).then(() => true, () => false);
|
|
83405
83405
|
}
|
|
83406
|
+
async function readManifest(target2) {
|
|
83407
|
+
try {
|
|
83408
|
+
return JSON.parse(await readFile17(target2, "utf8"));
|
|
83409
|
+
} catch (cause) {
|
|
83410
|
+
throw new UiError("UI_PROJECT_UNSUPPORTED", "package.json is not valid JSON.", undefined, {
|
|
83411
|
+
cause
|
|
83412
|
+
});
|
|
83413
|
+
}
|
|
83414
|
+
}
|
|
83415
|
+
function hasReactTailwind(manifest) {
|
|
83416
|
+
const dependencies = {
|
|
83417
|
+
...manifest.dependencies,
|
|
83418
|
+
...manifest.devDependencies,
|
|
83419
|
+
...manifest.peerDependencies
|
|
83420
|
+
};
|
|
83421
|
+
return Boolean(dependencies.react && dependencies["react-dom"] && dependencies.tailwindcss);
|
|
83422
|
+
}
|
|
83423
|
+
async function assertPhysicalProjectPath(root, target2) {
|
|
83424
|
+
const physicalRoot = await realpath2(root);
|
|
83425
|
+
let existing = target2;
|
|
83426
|
+
while (!await exists(existing)) {
|
|
83427
|
+
const parent = path5.dirname(existing);
|
|
83428
|
+
if (parent === existing)
|
|
83429
|
+
break;
|
|
83430
|
+
existing = parent;
|
|
83431
|
+
}
|
|
83432
|
+
const physicalTarget = await realpath2(existing);
|
|
83433
|
+
const relative = path5.relative(physicalRoot, physicalTarget);
|
|
83434
|
+
if (relative === ".." || relative.startsWith(".." + path5.sep) || path5.isAbsolute(relative)) {
|
|
83435
|
+
throw new UiError("UI_PROJECT_UNSUPPORTED", "components.json CSS path escapes the project.");
|
|
83436
|
+
}
|
|
83437
|
+
}
|
|
83406
83438
|
async function discoverUiProject(input = process.cwd()) {
|
|
83407
83439
|
let root = path5.resolve(input);
|
|
83408
83440
|
if (!await exists(root)) {
|
|
@@ -83410,7 +83442,19 @@ async function discoverUiProject(input = process.cwd()) {
|
|
|
83410
83442
|
}
|
|
83411
83443
|
if (!(await lstat(root)).isDirectory())
|
|
83412
83444
|
root = path5.dirname(root);
|
|
83413
|
-
while (
|
|
83445
|
+
while (true) {
|
|
83446
|
+
const manifestPath = path5.join(root, "package.json");
|
|
83447
|
+
if (await exists(manifestPath)) {
|
|
83448
|
+
const manifest = await readManifest(manifestPath);
|
|
83449
|
+
const parent2 = path5.dirname(root);
|
|
83450
|
+
if (hasReactTailwind(manifest) || parent2 === root)
|
|
83451
|
+
break;
|
|
83452
|
+
if (await exists(path5.join(parent2, "package.json"))) {
|
|
83453
|
+
root = parent2;
|
|
83454
|
+
continue;
|
|
83455
|
+
}
|
|
83456
|
+
break;
|
|
83457
|
+
}
|
|
83414
83458
|
const parent = path5.dirname(root);
|
|
83415
83459
|
if (parent === root) {
|
|
83416
83460
|
throw new UiError("UI_PROJECT_UNSUPPORTED", "No package.json found from " + path5.resolve(input), "Run the command inside an existing React application or pass its path.");
|
|
@@ -83418,14 +83462,7 @@ async function discoverUiProject(input = process.cwd()) {
|
|
|
83418
83462
|
root = parent;
|
|
83419
83463
|
}
|
|
83420
83464
|
const packageJsonPath = path5.join(root, "package.json");
|
|
83421
|
-
|
|
83422
|
-
try {
|
|
83423
|
-
packageJson = JSON.parse(await readFile17(packageJsonPath, "utf8"));
|
|
83424
|
-
} catch (cause) {
|
|
83425
|
-
throw new UiError("UI_PROJECT_UNSUPPORTED", "package.json is not valid JSON.", undefined, {
|
|
83426
|
-
cause
|
|
83427
|
-
});
|
|
83428
|
-
}
|
|
83465
|
+
const packageJson = await readManifest(packageJsonPath);
|
|
83429
83466
|
let manager = "npm";
|
|
83430
83467
|
let lockPath;
|
|
83431
83468
|
for (const [file2, candidate2] of MANAGERS) {
|
|
@@ -83452,9 +83489,34 @@ async function discoverUiProject(input = process.cwd()) {
|
|
|
83452
83489
|
}[manager];
|
|
83453
83490
|
lockPath = path5.join(root, expectedLock);
|
|
83454
83491
|
}
|
|
83455
|
-
const
|
|
83492
|
+
const rootCssCandidates = ["src/index.css", "src/app.css", "app/globals.css", "src/styles.css"];
|
|
83493
|
+
const frontendCssCandidates = [
|
|
83494
|
+
"frontend/src/index.css",
|
|
83495
|
+
"frontend/src/app.css",
|
|
83496
|
+
"frontend/src/styles.css"
|
|
83497
|
+
];
|
|
83498
|
+
const componentsPath = path5.join(root, "components.json");
|
|
83499
|
+
const configuredCss = await readFile17(componentsPath, "utf8").then((value3) => {
|
|
83500
|
+
const components = JSON.parse(value3);
|
|
83501
|
+
const css = components.tailwind?.css;
|
|
83502
|
+
if (typeof css !== "string" || css.length === 0)
|
|
83503
|
+
return;
|
|
83504
|
+
const target2 = path5.resolve(root, css);
|
|
83505
|
+
const relative = path5.relative(root, target2);
|
|
83506
|
+
if (relative === ".." || relative.startsWith(".." + path5.sep) || path5.isAbsolute(relative)) {
|
|
83507
|
+
throw new UiError("UI_PROJECT_UNSUPPORTED", "components.json CSS path escapes the project.");
|
|
83508
|
+
}
|
|
83509
|
+
return { relative: relative.split(path5.sep).join("/"), target: target2 };
|
|
83510
|
+
}).catch((error52) => {
|
|
83511
|
+
if (error52 instanceof UiError)
|
|
83512
|
+
throw error52;
|
|
83513
|
+
return;
|
|
83514
|
+
});
|
|
83515
|
+
const configuredCssRelative = configuredCss?.relative;
|
|
83516
|
+
const cssCandidates = configuredCssRelative !== undefined ? [configuredCssRelative] : await exists(path5.join(root, "frontend/package.json")) ? [...frontendCssCandidates, ...rootCssCandidates] : [...rootCssCandidates, ...frontendCssCandidates];
|
|
83456
83517
|
const resolvedCss = await Promise.all(cssCandidates.map(async (file2) => await exists(path5.join(root, file2)) ? file2 : undefined));
|
|
83457
|
-
const cssRelative = resolvedCss.find(Boolean) ?? "src/astrale-ui.css";
|
|
83518
|
+
const cssRelative = configuredCssRelative ?? resolvedCss.find(Boolean) ?? "src/astrale-ui.css";
|
|
83519
|
+
await assertPhysicalProjectPath(root, path5.join(root, cssRelative));
|
|
83458
83520
|
return {
|
|
83459
83521
|
root,
|
|
83460
83522
|
packageJsonPath,
|
|
@@ -83462,7 +83524,7 @@ async function discoverUiProject(input = process.cwd()) {
|
|
|
83462
83524
|
manager,
|
|
83463
83525
|
lockPath,
|
|
83464
83526
|
cssPath: path5.join(root, cssRelative),
|
|
83465
|
-
componentsPath
|
|
83527
|
+
componentsPath,
|
|
83466
83528
|
uiLockPath: path5.join(root, "astrale-ui.lock.json")
|
|
83467
83529
|
};
|
|
83468
83530
|
}
|
|
@@ -83549,11 +83611,14 @@ async function json4(fetcher, url3, label) {
|
|
|
83549
83611
|
}
|
|
83550
83612
|
}
|
|
83551
83613
|
async function resolveUiRelease(requested, fetcher = fetch) {
|
|
83552
|
-
const versionDocument = requested ? { version: requested.replace(/^v/u, "") } : await json4(fetcher, NPM_PACKAGE + "/
|
|
83614
|
+
const versionDocument = requested ? { version: requested.replace(/^v/u, "") } : await json4(fetcher, NPM_PACKAGE + "/beta", "npm UI release");
|
|
83553
83615
|
const version2 = versionDocument.version;
|
|
83554
83616
|
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(version2)) {
|
|
83555
83617
|
throw new UiError("UI_REGISTRY_UNAVAILABLE", "Invalid UI release version: " + version2);
|
|
83556
83618
|
}
|
|
83619
|
+
if (!requested && !/-beta\.\d+$/u.test(version2)) {
|
|
83620
|
+
throw new UiError("UI_REGISTRY_UNAVAILABLE", "Invalid UI beta release version: " + version2);
|
|
83621
|
+
}
|
|
83557
83622
|
const ref2 = "v" + version2;
|
|
83558
83623
|
const reference4 = await json4(fetcher, GITHUB_API + "/git/ref/tags/" + encodeURIComponent(ref2), "UI ref " + ref2);
|
|
83559
83624
|
const commit = reference4.object.type === "commit" ? reference4.object.sha : (await json4(fetcher, reference4.object.url, "annotated UI tag " + ref2)).object.sha;
|
|
@@ -83656,7 +83721,7 @@ var init_runner = __esm(() => {
|
|
|
83656
83721
|
});
|
|
83657
83722
|
|
|
83658
83723
|
// src/ui/operations.ts
|
|
83659
|
-
import { access as access3, lstat as lstat2, mkdir as mkdir10, readFile as readFile18, realpath as
|
|
83724
|
+
import { access as access3, lstat as lstat2, mkdir as mkdir10, readFile as readFile18, realpath as realpath3, rm as rm3, writeFile as writeFile6 } from "node:fs/promises";
|
|
83660
83725
|
import path6 from "node:path";
|
|
83661
83726
|
async function exists2(target2) {
|
|
83662
83727
|
return access3(target2).then(() => true, () => false);
|
|
@@ -84014,8 +84079,8 @@ async function safeTarget(project2, relative) {
|
|
|
84014
84079
|
}
|
|
84015
84080
|
const target2 = path6.resolve(project2.root, relative);
|
|
84016
84081
|
projectRelative(project2, target2);
|
|
84017
|
-
const parent = await
|
|
84018
|
-
const root = await
|
|
84082
|
+
const parent = await realpath3(path6.dirname(target2));
|
|
84083
|
+
const root = await realpath3(project2.root);
|
|
84019
84084
|
if (parent !== root && !parent.startsWith(root + path6.sep)) {
|
|
84020
84085
|
throw new UiError("UI_LOCK_INVALID", "Registry target escapes through a symlink: " + relative);
|
|
84021
84086
|
}
|
|
@@ -84030,7 +84095,7 @@ async function assertSafePlannedTarget(project2, relative) {
|
|
|
84030
84095
|
}
|
|
84031
84096
|
const target2 = path6.resolve(project2.root, relative);
|
|
84032
84097
|
projectRelative(project2, target2);
|
|
84033
|
-
const root = await
|
|
84098
|
+
const root = await realpath3(project2.root);
|
|
84034
84099
|
const physicalTarget = path6.resolve(root, path6.relative(project2.root, target2));
|
|
84035
84100
|
let current = root;
|
|
84036
84101
|
for (const segment of path6.relative(root, physicalTarget).split(path6.sep)) {
|
|
@@ -88103,7 +88168,7 @@ function registerGroup(parent, group) {
|
|
|
88103
88168
|
// src/program/build.ts
|
|
88104
88169
|
async function buildProgram() {
|
|
88105
88170
|
const program4 = new Command2;
|
|
88106
|
-
program4.name("astrale").description("Astrale CLI — connect to existing Astrale kernels").version(package_default.version).showSuggestionAfterError(true).addOption(new Option2("--ci", "Machine mode: no prompts, structured errors on stderr")).addOption(new Option2("--no-prompt", "Disable interactive prompts")).action(async () => {
|
|
88171
|
+
program4.name("astrale").description("Astrale CLI — connect to existing Astrale kernels").version(package_default.version, "-V, --cli-version", "output the CLI version (root alias: --version)").showSuggestionAfterError(true).addOption(new Option2("--ci", "Machine mode: no prompts, structured errors on stderr")).addOption(new Option2("--no-prompt", "Disable interactive prompts")).action(async () => {
|
|
88107
88172
|
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
88108
88173
|
const { shouldAutostartSetup: shouldAutostartSetup2 } = await Promise.resolve().then(() => (init_engine(), exports_engine));
|
|
88109
88174
|
if (await shouldAutostartSetup2()) {
|
|
@@ -88263,6 +88328,16 @@ Examples:
|
|
|
88263
88328
|
`);
|
|
88264
88329
|
return program4;
|
|
88265
88330
|
}
|
|
88331
|
+
// src/program/argv.ts
|
|
88332
|
+
function normalizeRootVersionArgv(argv) {
|
|
88333
|
+
const normalized = [...argv];
|
|
88334
|
+
const commandIndex = normalized.findIndex((token, index3) => index3 >= 2 && !token.startsWith("-"));
|
|
88335
|
+
const versionIndex = normalized.indexOf("--version", 2);
|
|
88336
|
+
if (versionIndex !== -1 && (commandIndex === -1 || versionIndex < commandIndex)) {
|
|
88337
|
+
normalized[versionIndex] = "--cli-version";
|
|
88338
|
+
}
|
|
88339
|
+
return normalized;
|
|
88340
|
+
}
|
|
88266
88341
|
// src/telemetry/recorder.ts
|
|
88267
88342
|
import { appendFileSync as appendFileSync2 } from "node:fs";
|
|
88268
88343
|
|
|
@@ -88378,7 +88453,15 @@ function ensureSession(cwd) {
|
|
|
88378
88453
|
init_settings();
|
|
88379
88454
|
init_store();
|
|
88380
88455
|
var NOOP = () => {};
|
|
88381
|
-
var HELP_VERSION = new Set([
|
|
88456
|
+
var HELP_VERSION = new Set([
|
|
88457
|
+
"-h",
|
|
88458
|
+
"--help",
|
|
88459
|
+
"-V",
|
|
88460
|
+
"--version",
|
|
88461
|
+
"--cli-version",
|
|
88462
|
+
"help",
|
|
88463
|
+
"version"
|
|
88464
|
+
]);
|
|
88382
88465
|
function isHelpOrVersion(args) {
|
|
88383
88466
|
return args.every((a) => HELP_VERSION.has(a));
|
|
88384
88467
|
}
|
|
@@ -88500,14 +88583,15 @@ var errorName;
|
|
|
88500
88583
|
if (finalize2)
|
|
88501
88584
|
process.on("exit", (code) => finalize2(code ?? 0, errorName));
|
|
88502
88585
|
maybeTriggerAnalysis(process.argv);
|
|
88503
|
-
|
|
88586
|
+
var argv = normalizeRootVersionArgv(process.argv);
|
|
88587
|
+
configureInvocation(argv);
|
|
88504
88588
|
var program4 = await buildProgram();
|
|
88505
88589
|
overrideExits(program4);
|
|
88506
88590
|
program4.configureOutput({ writeErr: () => {
|
|
88507
88591
|
return;
|
|
88508
88592
|
} });
|
|
88509
88593
|
try {
|
|
88510
|
-
await program4.parseAsync();
|
|
88594
|
+
await program4.parseAsync(argv);
|
|
88511
88595
|
} catch (error52) {
|
|
88512
88596
|
if (error52 instanceof CommanderError) {
|
|
88513
88597
|
if (error52.exitCode === 0)
|
package/package.json
CHANGED
|
@@ -72,6 +72,21 @@ describe('UI command machine contracts', () => {
|
|
|
72
72
|
expect(JSON.parse(stdout)).toEqual([item])
|
|
73
73
|
})
|
|
74
74
|
|
|
75
|
+
test('list forwards an explicit release without consulting the beta channel', async () => {
|
|
76
|
+
const seen: string[] = []
|
|
77
|
+
globalThis.fetch = mockFetch(seen)
|
|
78
|
+
const action = listCommand.action as (
|
|
79
|
+
query: string | undefined,
|
|
80
|
+
options: { json?: boolean; limit?: string; version?: string },
|
|
81
|
+
) => Promise<void>
|
|
82
|
+
|
|
83
|
+
await action('line-basic', { json: true, limit: '100', version: '0.3.0-beta.1' })
|
|
84
|
+
|
|
85
|
+
expect(JSON.parse(stdout)).toEqual([item])
|
|
86
|
+
expect(seen.some((url) => url.endsWith('/@astrale-os/ui/beta'))).toBe(false)
|
|
87
|
+
expect(seen.some((url) => url.includes('/git/ref/tags/v0.3.0-beta.1'))).toBe(true)
|
|
88
|
+
})
|
|
89
|
+
|
|
75
90
|
test('add rejects missing items without prompting in machine mode', async () => {
|
|
76
91
|
process.argv = ['node', 'astrale', '--no-prompt', 'ui', 'add']
|
|
77
92
|
const add = addCommand.action as (items: string[], options: { json?: boolean }) => Promise<void>
|
|
@@ -99,10 +114,11 @@ describe('UI command machine contracts', () => {
|
|
|
99
114
|
})
|
|
100
115
|
})
|
|
101
116
|
|
|
102
|
-
function mockFetch(): typeof fetch {
|
|
117
|
+
function mockFetch(seen: string[] = []): typeof fetch {
|
|
103
118
|
return (async (input: string | URL | Request) => {
|
|
104
119
|
const url = String(input)
|
|
105
|
-
|
|
120
|
+
seen.push(url)
|
|
121
|
+
if (url.endsWith('/@astrale-os/ui/beta')) return Response.json({ version: '0.3.0-beta.1' })
|
|
106
122
|
if (url.includes('/git/ref/tags/')) {
|
|
107
123
|
return Response.json({ object: { type: 'commit', sha: commit, url: '' } })
|
|
108
124
|
}
|
|
@@ -5,7 +5,7 @@ import { createHash } from 'node:crypto'
|
|
|
5
5
|
import { existsSync, readFileSync } from 'node:fs'
|
|
6
6
|
import { join } from 'node:path'
|
|
7
7
|
|
|
8
|
-
import { buildProgram } from '../index'
|
|
8
|
+
import { buildProgram, normalizeRootVersionArgv } from '../index'
|
|
9
9
|
|
|
10
10
|
// Help output is the public CLI contract: version, spec anchors, and skill mirror stay in sync.
|
|
11
11
|
|
|
@@ -195,7 +195,7 @@ describe('program composition', () => {
|
|
|
195
195
|
'whoami',
|
|
196
196
|
])
|
|
197
197
|
expect(createHash('sha256').update(JSON.stringify(surface)).digest('hex')).toBe(
|
|
198
|
-
'
|
|
198
|
+
'd37c3d80b3067b3553b8527b10bf1c5632922d624b3d6072c3b03e7a507efc4f',
|
|
199
199
|
)
|
|
200
200
|
})
|
|
201
201
|
|
|
@@ -312,6 +312,99 @@ describe('help contract — connect-only command surface', () => {
|
|
|
312
312
|
})
|
|
313
313
|
|
|
314
314
|
describe('help contract — UI is project tooling', () => {
|
|
315
|
+
test('keeps the documented root version alias operational', async () => {
|
|
316
|
+
const program = await buildProgram()
|
|
317
|
+
let stdout = ''
|
|
318
|
+
program.exitOverride().configureOutput({
|
|
319
|
+
writeOut: (chunk) => {
|
|
320
|
+
stdout += chunk ?? ''
|
|
321
|
+
},
|
|
322
|
+
})
|
|
323
|
+
|
|
324
|
+
await expect(
|
|
325
|
+
program.parseAsync(normalizeRootVersionArgv(['node', 'astrale', '--version'])),
|
|
326
|
+
).rejects.toMatchObject({ code: 'commander.version' })
|
|
327
|
+
expect(stdout.trim()).toBe(program.version() ?? '')
|
|
328
|
+
expect(program.helpInformation()).toMatch(/root alias:\s+--version/u)
|
|
329
|
+
})
|
|
330
|
+
|
|
331
|
+
test('routes root and subcommand version flags to their exact owners', async () => {
|
|
332
|
+
const program = await buildProgram()
|
|
333
|
+
const uiList = program.commands
|
|
334
|
+
.find((command) => command.name() === 'ui')
|
|
335
|
+
?.commands.find((command) => command.name() === 'list')
|
|
336
|
+
let observedVersion: unknown
|
|
337
|
+
uiList?.action((_query, options) => {
|
|
338
|
+
observedVersion = options.version
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
const uiArgv = ['node', 'astrale', 'ui', 'list', 'chart', '--version', '0.3.0-beta.1']
|
|
342
|
+
expect(normalizeRootVersionArgv(uiArgv)).toEqual(uiArgv)
|
|
343
|
+
expect(normalizeRootVersionArgv(['node', 'astrale', '--version'])).toEqual([
|
|
344
|
+
'node',
|
|
345
|
+
'astrale',
|
|
346
|
+
'--cli-version',
|
|
347
|
+
])
|
|
348
|
+
expect(normalizeRootVersionArgv(['node', 'astrale', '--ci', '--version'])).toEqual([
|
|
349
|
+
'node',
|
|
350
|
+
'astrale',
|
|
351
|
+
'--ci',
|
|
352
|
+
'--cli-version',
|
|
353
|
+
])
|
|
354
|
+
|
|
355
|
+
await program.parseAsync(uiArgv)
|
|
356
|
+
|
|
357
|
+
expect(observedVersion).toBe('0.3.0-beta.1')
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('routes explicit versions with positional inputs for UI init and update', async () => {
|
|
361
|
+
const program = await buildProgram()
|
|
362
|
+
const uiInit = program.commands
|
|
363
|
+
.find((command) => command.name() === 'ui')
|
|
364
|
+
?.commands.find((command) => command.name() === 'init')
|
|
365
|
+
const update = program.commands.find((command) => command.name() === 'update')
|
|
366
|
+
let initializedPath: unknown
|
|
367
|
+
let initializedVersion: unknown
|
|
368
|
+
let updateVersion: unknown
|
|
369
|
+
uiInit?.action((projectPath, options) => {
|
|
370
|
+
initializedPath = projectPath
|
|
371
|
+
initializedVersion = options.version
|
|
372
|
+
})
|
|
373
|
+
update?.action((options) => {
|
|
374
|
+
updateVersion = options.version
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
await program.parseAsync([
|
|
378
|
+
'node',
|
|
379
|
+
'astrale',
|
|
380
|
+
'ui',
|
|
381
|
+
'init',
|
|
382
|
+
'./app',
|
|
383
|
+
'--version',
|
|
384
|
+
'0.3.0-beta.1',
|
|
385
|
+
])
|
|
386
|
+
expect(initializedPath).toBe('./app')
|
|
387
|
+
expect(initializedVersion).toBe('0.3.0-beta.1')
|
|
388
|
+
|
|
389
|
+
await program.parseAsync(['node', 'astrale', 'update', '--version', '1.0.0-beta.13'])
|
|
390
|
+
expect(updateVersion).toBe('1.0.0-beta.13')
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
test('continues to admit global machine flags after a subcommand', async () => {
|
|
394
|
+
const program = await buildProgram()
|
|
395
|
+
const uiList = program.commands
|
|
396
|
+
.find((command) => command.name() === 'ui')
|
|
397
|
+
?.commands.find((command) => command.name() === 'list')
|
|
398
|
+
let invoked = false
|
|
399
|
+
uiList?.action(() => {
|
|
400
|
+
invoked = true
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
await program.parseAsync(['node', 'astrale', 'ui', 'list', '--ci', '--no-prompt'])
|
|
404
|
+
|
|
405
|
+
expect(invoked).toBe(true)
|
|
406
|
+
})
|
|
407
|
+
|
|
315
408
|
test('UI commands are local-only and add accepts zero or more canonical addresses', async () => {
|
|
316
409
|
const program = await buildProgram()
|
|
317
410
|
const ui = program.commands.find((command) => command.name() === 'ui')
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keep `astrale --version` as the familiar root spelling without claiming the
|
|
3
|
+
* same flag after a subcommand. Commands such as `ui list` and `update` own
|
|
4
|
+
* their release `--version` value.
|
|
5
|
+
*/
|
|
6
|
+
export function normalizeRootVersionArgv(argv: readonly string[]): string[] {
|
|
7
|
+
const normalized = [...argv]
|
|
8
|
+
const commandIndex = normalized.findIndex((token, index) => index >= 2 && !token.startsWith('-'))
|
|
9
|
+
const versionIndex = normalized.indexOf('--version', 2)
|
|
10
|
+
if (versionIndex !== -1 && (commandIndex === -1 || versionIndex < commandIndex)) {
|
|
11
|
+
normalized[versionIndex] = '--cli-version'
|
|
12
|
+
}
|
|
13
|
+
return normalized
|
|
14
|
+
}
|
package/src/program/build.ts
CHANGED
|
@@ -19,7 +19,7 @@ export async function buildProgram(): Promise<Command> {
|
|
|
19
19
|
.description('Astrale CLI — connect to existing Astrale kernels')
|
|
20
20
|
// Single source of truth = package.json (bumped by release-please together
|
|
21
21
|
// with .release-please-manifest.json). Never hand-write a version literal.
|
|
22
|
-
.version(pkg.version)
|
|
22
|
+
.version(pkg.version, '-V, --cli-version', 'output the CLI version (root alias: --version)')
|
|
23
23
|
.showSuggestionAfterError(true)
|
|
24
24
|
.addOption(new Option('--ci', 'Machine mode: no prompts, structured errors on stderr'))
|
|
25
25
|
.addOption(new Option('--no-prompt', 'Disable interactive prompts'))
|
package/src/program/index.ts
CHANGED
|
@@ -91,6 +91,7 @@ describe('beginInvocation', () => {
|
|
|
91
91
|
test('writes nothing for help/version-only invocations', () => {
|
|
92
92
|
beginInvocation(['node', 'astrale', '--help'])(0)
|
|
93
93
|
beginInvocation(['node', 'astrale', '--version'])(0)
|
|
94
|
+
beginInvocation(['node', 'astrale', '--cli-version'])(0)
|
|
94
95
|
beginInvocation(['node', 'astrale'])(0)
|
|
95
96
|
expect(listSessions()).toHaveLength(0)
|
|
96
97
|
})
|
|
@@ -17,7 +17,15 @@ import { eventsPath } from './store'
|
|
|
17
17
|
export type Finalizer = (exitCode: number, errorName?: string) => void
|
|
18
18
|
|
|
19
19
|
const NOOP: Finalizer = () => {}
|
|
20
|
-
const HELP_VERSION = new Set([
|
|
20
|
+
const HELP_VERSION = new Set([
|
|
21
|
+
'-h',
|
|
22
|
+
'--help',
|
|
23
|
+
'-V',
|
|
24
|
+
'--version',
|
|
25
|
+
'--cli-version',
|
|
26
|
+
'help',
|
|
27
|
+
'version',
|
|
28
|
+
])
|
|
21
29
|
|
|
22
30
|
/** Bare invocation (every() is true for []) or only help/version tokens — skip. */
|
|
23
31
|
function isHelpOrVersion(args: string[]): boolean {
|
|
@@ -114,6 +114,37 @@ function lock(): UiLock {
|
|
|
114
114
|
}
|
|
115
115
|
|
|
116
116
|
describe('UI release and runner contracts', () => {
|
|
117
|
+
test('resolves the default release from the public beta channel', async () => {
|
|
118
|
+
const seen: string[] = []
|
|
119
|
+
const fetcher = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
120
|
+
const url = String(input)
|
|
121
|
+
if (url.endsWith('/@astrale-os/ui/beta')) {
|
|
122
|
+
seen.push(url)
|
|
123
|
+
return Response.json({ version: '0.3.0-beta.1' })
|
|
124
|
+
}
|
|
125
|
+
return mockFetch(seen)(input, init)
|
|
126
|
+
}) as typeof fetch
|
|
127
|
+
|
|
128
|
+
const release = await resolveUiRelease(undefined, fetcher)
|
|
129
|
+
|
|
130
|
+
expect(release.version).toBe('0.3.0-beta.1')
|
|
131
|
+
expect(seen[0]).toBe('https://registry.npmjs.org/@astrale-os/ui/beta')
|
|
132
|
+
expect(seen).not.toContain('https://registry.npmjs.org/@astrale-os/ui/latest')
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
test('rejects a public beta channel that does not resolve to a beta release', async () => {
|
|
136
|
+
const fetcher = (async (input: string | URL | Request) => {
|
|
137
|
+
const url = String(input)
|
|
138
|
+
if (url.endsWith('/@astrale-os/ui/beta')) return Response.json({ version: '0.3.0' })
|
|
139
|
+
throw new Error('release snapshot must not be fetched')
|
|
140
|
+
}) as typeof fetch
|
|
141
|
+
|
|
142
|
+
await expect(resolveUiRelease(undefined, fetcher)).rejects.toMatchObject({
|
|
143
|
+
code: 'UI_REGISTRY_UNAVAILABLE',
|
|
144
|
+
message: 'Invalid UI beta release version: 0.3.0',
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
|
|
117
148
|
/** @evidence TEST-CLI-UI-ONE-SNAPSHOT */
|
|
118
149
|
test('resolves one commit and reads the full release snapshot from it', async () => {
|
|
119
150
|
const seen: string[] = []
|
|
@@ -211,6 +242,67 @@ describe('UI release and runner contracts', () => {
|
|
|
211
242
|
})
|
|
212
243
|
|
|
213
244
|
describe('UI initialization transaction', () => {
|
|
245
|
+
test('initializes the generated Domain frontend stylesheet instead of an unused fallback', async () => {
|
|
246
|
+
const root = await fixture()
|
|
247
|
+
await mkdir(path.join(root, 'frontend/src'), { recursive: true })
|
|
248
|
+
await writeFile(
|
|
249
|
+
path.join(root, 'frontend/package.json'),
|
|
250
|
+
JSON.stringify({ name: 'astrale-frontend', private: true, type: 'module' }),
|
|
251
|
+
)
|
|
252
|
+
await writeFile(path.join(root, 'frontend/src/styles.css'), '/* Domain frontend */\n')
|
|
253
|
+
const rootCssBefore = await readFile(path.join(root, 'src/index.css'), 'utf8')
|
|
254
|
+
|
|
255
|
+
await initUi(
|
|
256
|
+
{ path: path.join(root, 'frontend'), version: '0.3.0-beta.0', install: false },
|
|
257
|
+
{ fetcher: mockFetch() },
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
const css = await readFile(path.join(root, 'frontend/src/styles.css'), 'utf8')
|
|
261
|
+
const components = JSON.parse(await readFile(path.join(root, 'components.json'), 'utf8'))
|
|
262
|
+
expect(css).toContain("@import '@astrale-os/ui/theme.css';")
|
|
263
|
+
expect(css).toContain('/* Domain frontend */')
|
|
264
|
+
expect(components.tailwind.css).toBe('frontend/src/styles.css')
|
|
265
|
+
expect(await readFile(path.join(root, 'src/index.css'), 'utf8')).toBe(rootCssBefore)
|
|
266
|
+
expect(
|
|
267
|
+
JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')).dependencies,
|
|
268
|
+
).toHaveProperty('@astrale-os/ui')
|
|
269
|
+
expect(await Bun.file(path.join(root, 'src/astrale-ui.css')).exists()).toBe(false)
|
|
270
|
+
|
|
271
|
+
await writeFile(path.join(root, 'src/app.css'), '/* later root stylesheet */\n')
|
|
272
|
+
const repeated = await initUi(
|
|
273
|
+
{ path: root, version: '0.3.0-beta.0', install: false },
|
|
274
|
+
{ fetcher: mockFetch() },
|
|
275
|
+
)
|
|
276
|
+
expect(repeated.status).toBe('unchanged')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
test('rejects configured and discovered stylesheets whose physical parent escapes', async () => {
|
|
280
|
+
const root = await fixture()
|
|
281
|
+
const outside = await fixture()
|
|
282
|
+
await symlink(outside, path.join(root, 'escaped'), 'dir')
|
|
283
|
+
await writeFile(
|
|
284
|
+
path.join(root, 'components.json'),
|
|
285
|
+
JSON.stringify({ tailwind: { css: 'escaped/styles.css' } }),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
await expect(
|
|
289
|
+
initUi({ path: root, version: '0.3.0-beta.0', install: false }, { fetcher: mockFetch() }),
|
|
290
|
+
).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
|
|
291
|
+
expect(await Bun.file(path.join(outside, 'styles.css')).exists()).toBe(false)
|
|
292
|
+
|
|
293
|
+
const automaticRoot = await fixture()
|
|
294
|
+
await rm(path.join(automaticRoot, 'src/index.css'))
|
|
295
|
+
const outsideCss = await readFile(path.join(outside, 'src/index.css'), 'utf8')
|
|
296
|
+
await symlink(outside, path.join(automaticRoot, 'frontend'), 'dir')
|
|
297
|
+
await expect(
|
|
298
|
+
initUi(
|
|
299
|
+
{ path: automaticRoot, version: '0.3.0-beta.0', install: false },
|
|
300
|
+
{ fetcher: mockFetch() },
|
|
301
|
+
),
|
|
302
|
+
).rejects.toMatchObject({ code: 'UI_PROJECT_UNSUPPORTED' })
|
|
303
|
+
expect(await readFile(path.join(outside, 'src/index.css'), 'utf8')).toBe(outsideCss)
|
|
304
|
+
})
|
|
305
|
+
|
|
214
306
|
test('dry-run reports every mutation and writes nothing', async () => {
|
|
215
307
|
const root = await fixture()
|
|
216
308
|
const before = await readFile(path.join(root, 'package.json'), 'utf8')
|
package/src/ui/project.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { access, lstat, readFile } from 'node:fs/promises'
|
|
1
|
+
import { access, lstat, readFile, realpath } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
|
|
4
4
|
import { UiError, type PackageManager } from './model'
|
|
@@ -29,6 +29,40 @@ async function exists(target: string): Promise<boolean> {
|
|
|
29
29
|
)
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
async function readManifest(target: string): Promise<Record<string, unknown>> {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(await readFile(target, 'utf8')) as Record<string, unknown>
|
|
35
|
+
} catch (cause) {
|
|
36
|
+
throw new UiError('UI_PROJECT_UNSUPPORTED', 'package.json is not valid JSON.', undefined, {
|
|
37
|
+
cause,
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function hasReactTailwind(manifest: Record<string, unknown>): boolean {
|
|
43
|
+
const dependencies = {
|
|
44
|
+
...(manifest.dependencies as Record<string, string> | undefined),
|
|
45
|
+
...(manifest.devDependencies as Record<string, string> | undefined),
|
|
46
|
+
...(manifest.peerDependencies as Record<string, string> | undefined),
|
|
47
|
+
}
|
|
48
|
+
return Boolean(dependencies.react && dependencies['react-dom'] && dependencies.tailwindcss)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function assertPhysicalProjectPath(root: string, target: string): Promise<void> {
|
|
52
|
+
const physicalRoot = await realpath(root)
|
|
53
|
+
let existing = target
|
|
54
|
+
while (!(await exists(existing))) {
|
|
55
|
+
const parent = path.dirname(existing)
|
|
56
|
+
if (parent === existing) break
|
|
57
|
+
existing = parent
|
|
58
|
+
}
|
|
59
|
+
const physicalTarget = await realpath(existing)
|
|
60
|
+
const relative = path.relative(physicalRoot, physicalTarget)
|
|
61
|
+
if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) {
|
|
62
|
+
throw new UiError('UI_PROJECT_UNSUPPORTED', 'components.json CSS path escapes the project.')
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
32
66
|
export async function discoverUiProject(input = process.cwd()): Promise<UiProject> {
|
|
33
67
|
let root = path.resolve(input)
|
|
34
68
|
if (!(await exists(root))) {
|
|
@@ -36,7 +70,18 @@ export async function discoverUiProject(input = process.cwd()): Promise<UiProjec
|
|
|
36
70
|
}
|
|
37
71
|
if (!(await lstat(root)).isDirectory()) root = path.dirname(root)
|
|
38
72
|
|
|
39
|
-
while (
|
|
73
|
+
while (true) {
|
|
74
|
+
const manifestPath = path.join(root, 'package.json')
|
|
75
|
+
if (await exists(manifestPath)) {
|
|
76
|
+
const manifest = await readManifest(manifestPath)
|
|
77
|
+
const parent = path.dirname(root)
|
|
78
|
+
if (hasReactTailwind(manifest) || parent === root) break
|
|
79
|
+
if (await exists(path.join(parent, 'package.json'))) {
|
|
80
|
+
root = parent
|
|
81
|
+
continue
|
|
82
|
+
}
|
|
83
|
+
break
|
|
84
|
+
}
|
|
40
85
|
const parent = path.dirname(root)
|
|
41
86
|
if (parent === root) {
|
|
42
87
|
throw new UiError(
|
|
@@ -49,14 +94,7 @@ export async function discoverUiProject(input = process.cwd()): Promise<UiProjec
|
|
|
49
94
|
}
|
|
50
95
|
|
|
51
96
|
const packageJsonPath = path.join(root, 'package.json')
|
|
52
|
-
|
|
53
|
-
try {
|
|
54
|
-
packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8')) as Record<string, unknown>
|
|
55
|
-
} catch (cause) {
|
|
56
|
-
throw new UiError('UI_PROJECT_UNSUPPORTED', 'package.json is not valid JSON.', undefined, {
|
|
57
|
-
cause,
|
|
58
|
-
})
|
|
59
|
-
}
|
|
97
|
+
const packageJson = await readManifest(packageJsonPath)
|
|
60
98
|
|
|
61
99
|
let manager: PackageManager = 'npm'
|
|
62
100
|
let lockPath: string | undefined
|
|
@@ -90,11 +128,41 @@ export async function discoverUiProject(input = process.cwd()): Promise<UiProjec
|
|
|
90
128
|
lockPath = path.join(root, expectedLock)
|
|
91
129
|
}
|
|
92
130
|
|
|
93
|
-
const
|
|
131
|
+
const rootCssCandidates = ['src/index.css', 'src/app.css', 'app/globals.css', 'src/styles.css']
|
|
132
|
+
const frontendCssCandidates = [
|
|
133
|
+
'frontend/src/index.css',
|
|
134
|
+
'frontend/src/app.css',
|
|
135
|
+
'frontend/src/styles.css',
|
|
136
|
+
]
|
|
137
|
+
const componentsPath = path.join(root, 'components.json')
|
|
138
|
+
const configuredCss = await readFile(componentsPath, 'utf8')
|
|
139
|
+
.then((value) => {
|
|
140
|
+
const components = JSON.parse(value) as { tailwind?: { css?: unknown } }
|
|
141
|
+
const css = components.tailwind?.css
|
|
142
|
+
if (typeof css !== 'string' || css.length === 0) return undefined
|
|
143
|
+
const target = path.resolve(root, css)
|
|
144
|
+
const relative = path.relative(root, target)
|
|
145
|
+
if (relative === '..' || relative.startsWith('..' + path.sep) || path.isAbsolute(relative)) {
|
|
146
|
+
throw new UiError('UI_PROJECT_UNSUPPORTED', 'components.json CSS path escapes the project.')
|
|
147
|
+
}
|
|
148
|
+
return { relative: relative.split(path.sep).join('/'), target }
|
|
149
|
+
})
|
|
150
|
+
.catch((error: unknown) => {
|
|
151
|
+
if (error instanceof UiError) throw error
|
|
152
|
+
return undefined
|
|
153
|
+
})
|
|
154
|
+
const configuredCssRelative = configuredCss?.relative
|
|
155
|
+
const cssCandidates =
|
|
156
|
+
configuredCssRelative !== undefined
|
|
157
|
+
? [configuredCssRelative]
|
|
158
|
+
: (await exists(path.join(root, 'frontend/package.json')))
|
|
159
|
+
? [...frontendCssCandidates, ...rootCssCandidates]
|
|
160
|
+
: [...rootCssCandidates, ...frontendCssCandidates]
|
|
94
161
|
const resolvedCss = await Promise.all(
|
|
95
162
|
cssCandidates.map(async (file) => ((await exists(path.join(root, file))) ? file : undefined)),
|
|
96
163
|
)
|
|
97
|
-
const cssRelative = resolvedCss.find(Boolean) ?? 'src/astrale-ui.css'
|
|
164
|
+
const cssRelative = configuredCssRelative ?? resolvedCss.find(Boolean) ?? 'src/astrale-ui.css'
|
|
165
|
+
await assertPhysicalProjectPath(root, path.join(root, cssRelative))
|
|
98
166
|
|
|
99
167
|
return {
|
|
100
168
|
root,
|
|
@@ -103,7 +171,7 @@ export async function discoverUiProject(input = process.cwd()): Promise<UiProjec
|
|
|
103
171
|
manager,
|
|
104
172
|
lockPath,
|
|
105
173
|
cssPath: path.join(root, cssRelative),
|
|
106
|
-
componentsPath
|
|
174
|
+
componentsPath,
|
|
107
175
|
uiLockPath: path.join(root, 'astrale-ui.lock.json'),
|
|
108
176
|
}
|
|
109
177
|
}
|
package/src/ui/release.ts
CHANGED
|
@@ -67,11 +67,14 @@ export async function resolveUiRelease(
|
|
|
67
67
|
): Promise<UiRelease> {
|
|
68
68
|
const versionDocument = requested
|
|
69
69
|
? { version: requested.replace(/^v/u, '') }
|
|
70
|
-
: await json<{ version: string }>(fetcher, NPM_PACKAGE + '/
|
|
70
|
+
: await json<{ version: string }>(fetcher, NPM_PACKAGE + '/beta', 'npm UI release')
|
|
71
71
|
const version = versionDocument.version
|
|
72
72
|
if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/u.test(version)) {
|
|
73
73
|
throw new UiError('UI_REGISTRY_UNAVAILABLE', 'Invalid UI release version: ' + version)
|
|
74
74
|
}
|
|
75
|
+
if (!requested && !/-beta\.\d+$/u.test(version)) {
|
|
76
|
+
throw new UiError('UI_REGISTRY_UNAVAILABLE', 'Invalid UI beta release version: ' + version)
|
|
77
|
+
}
|
|
75
78
|
const ref = 'v' + version
|
|
76
79
|
const reference = await json<{
|
|
77
80
|
object: { type: 'commit' | 'tag'; sha: string; url: string }
|