@astrale-os/cli 1.0.0-beta.19 → 1.0.0-beta.20
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 +249 -68
- package/dist/public/connect-core.js +143 -2
- package/dist/types/lib/update.d.ts +20 -2
- package/package.json +3 -3
- package/src/commands/__tests__/view.test.ts +49 -0
- package/src/commands/view.ts +75 -31
- package/src/lib/__tests__/update.test.ts +164 -4
- package/src/lib/__tests__/view-assets.test.ts +14 -0
- package/src/lib/__tests__/view-external-open-intent.test.ts +150 -0
- package/src/lib/__tests__/view-external-open-origins.test.ts +27 -0
- package/src/lib/__tests__/view-server.test.ts +8 -0
- package/src/lib/__tests__/view-session.test.ts +1 -0
- package/src/lib/update.ts +178 -19
- package/src/lib/view/assets.ts +11 -2
- package/src/lib/view/external-open-intent.ts +41 -0
- package/src/lib/view/external-open-origins.ts +37 -0
- package/src/lib/view/host-capabilities.ts +17 -0
- package/src/lib/view/server.ts +1 -0
- package/src/lib/view/session.ts +2 -0
- package/src/program/__tests__/program.test.ts +1 -1
- package/studio/package.json +2 -2
- package/viewer/dist/main.js +28 -28
|
@@ -24476,6 +24476,18 @@ class AdminInstanceNotFoundError extends AstraleError {
|
|
|
24476
24476
|
}
|
|
24477
24477
|
|
|
24478
24478
|
// src/lib/update.ts
|
|
24479
|
+
import {
|
|
24480
|
+
chmod,
|
|
24481
|
+
copyFile,
|
|
24482
|
+
mkdir as mkdir5,
|
|
24483
|
+
mkdtemp,
|
|
24484
|
+
readFile as readFile6,
|
|
24485
|
+
realpath,
|
|
24486
|
+
rename as rename2,
|
|
24487
|
+
rm,
|
|
24488
|
+
writeFile as writeFile2
|
|
24489
|
+
} from "node:fs/promises";
|
|
24490
|
+
import { dirname as dirname5, join as join4 } from "node:path";
|
|
24479
24491
|
var DEFAULT_REPO = "astrale-os/cli";
|
|
24480
24492
|
var DEFAULT_UPDATE_CHANNEL = "beta";
|
|
24481
24493
|
var InstallMetadataSchema = exports_external.object({
|
|
@@ -24501,6 +24513,135 @@ var UpdateManifestSchema = exports_external.object({
|
|
|
24501
24513
|
]))
|
|
24502
24514
|
});
|
|
24503
24515
|
var admittedScriptInstall = Symbol("admittedScriptInstall");
|
|
24516
|
+
function isMissingFile(error51) {
|
|
24517
|
+
return error51 instanceof Error && "code" in error51 && error51.code === "ENOENT";
|
|
24518
|
+
}
|
|
24519
|
+
async function writeInstallMetadata(meta3, path = INSTALL_PATH, filesystem = { mkdir: mkdir5, rename: rename2, rm, writeFile: writeFile2 }) {
|
|
24520
|
+
const staged = `${path}.next`;
|
|
24521
|
+
const previous = `${path}.previous`;
|
|
24522
|
+
await filesystem.mkdir(dirname5(path), { recursive: true });
|
|
24523
|
+
await filesystem.rm(staged, { force: true });
|
|
24524
|
+
await filesystem.rm(previous, { force: true });
|
|
24525
|
+
await filesystem.writeFile(staged, JSON.stringify(meta3, null, 2) + `
|
|
24526
|
+
`);
|
|
24527
|
+
let backedUp = false;
|
|
24528
|
+
try {
|
|
24529
|
+
try {
|
|
24530
|
+
await filesystem.rename(path, previous);
|
|
24531
|
+
backedUp = true;
|
|
24532
|
+
} catch (error51) {
|
|
24533
|
+
if (!isMissingFile(error51))
|
|
24534
|
+
throw error51;
|
|
24535
|
+
}
|
|
24536
|
+
await filesystem.rename(staged, path);
|
|
24537
|
+
await filesystem.rm(previous, { force: true });
|
|
24538
|
+
} catch (error51) {
|
|
24539
|
+
const rollback = [];
|
|
24540
|
+
if (backedUp) {
|
|
24541
|
+
await filesystem.rename(previous, path).catch((failure) => rollback.push(failure));
|
|
24542
|
+
}
|
|
24543
|
+
await filesystem.rm(staged, { force: true }).catch((failure) => rollback.push(failure));
|
|
24544
|
+
if (rollback.length > 0) {
|
|
24545
|
+
throw new AggregateError([error51, ...rollback], "Install metadata update and rollback both failed.");
|
|
24546
|
+
}
|
|
24547
|
+
throw error51;
|
|
24548
|
+
}
|
|
24549
|
+
}
|
|
24550
|
+
var defaultCohortFilesystem = { chmod, copyFile, mkdir: mkdir5, rename: rename2, rm };
|
|
24551
|
+
async function replaceStandaloneCohort(installedBinary, nextBinary, nextViewerDist, filesystem = {}) {
|
|
24552
|
+
const fs = { ...defaultCohortFilesystem, ...filesystem };
|
|
24553
|
+
const binDirectory = dirname5(installedBinary);
|
|
24554
|
+
const previousBinary = `${installedBinary}.previous`;
|
|
24555
|
+
const stagedBinary = `${installedBinary}.next`;
|
|
24556
|
+
const viewer = join4(binDirectory, "viewer");
|
|
24557
|
+
const previousViewer = join4(binDirectory, "viewer.previous");
|
|
24558
|
+
const stagedViewer = join4(binDirectory, "viewer.next");
|
|
24559
|
+
const stagedViewerDist = join4(stagedViewer, "dist");
|
|
24560
|
+
try {
|
|
24561
|
+
await fs.rm(stagedViewer, { recursive: true, force: true });
|
|
24562
|
+
await fs.mkdir(stagedViewerDist, { recursive: true });
|
|
24563
|
+
await fs.copyFile(join4(nextViewerDist, "main.js"), join4(stagedViewerDist, "main.js"));
|
|
24564
|
+
await fs.copyFile(join4(nextViewerDist, "index.html"), join4(stagedViewerDist, "index.html"));
|
|
24565
|
+
await fs.copyFile(installedBinary, previousBinary);
|
|
24566
|
+
await fs.copyFile(nextBinary, stagedBinary);
|
|
24567
|
+
await fs.chmod(stagedBinary, 493);
|
|
24568
|
+
} catch (error51) {
|
|
24569
|
+
await fs.rm(stagedViewer, { recursive: true, force: true }).catch(() => {
|
|
24570
|
+
return;
|
|
24571
|
+
});
|
|
24572
|
+
await fs.rm(stagedBinary, { force: true }).catch(() => {
|
|
24573
|
+
return;
|
|
24574
|
+
});
|
|
24575
|
+
throw error51;
|
|
24576
|
+
}
|
|
24577
|
+
await fs.rm(previousViewer, { recursive: true, force: true });
|
|
24578
|
+
let viewerBackedUp = false;
|
|
24579
|
+
let viewerCommitted = false;
|
|
24580
|
+
let binaryCommitted = false;
|
|
24581
|
+
try {
|
|
24582
|
+
try {
|
|
24583
|
+
await fs.rename(viewer, previousViewer);
|
|
24584
|
+
viewerBackedUp = true;
|
|
24585
|
+
} catch (error51) {
|
|
24586
|
+
if (error51.code !== "ENOENT")
|
|
24587
|
+
throw error51;
|
|
24588
|
+
}
|
|
24589
|
+
await fs.rename(stagedViewer, viewer);
|
|
24590
|
+
viewerCommitted = true;
|
|
24591
|
+
await fs.rename(stagedBinary, installedBinary);
|
|
24592
|
+
binaryCommitted = true;
|
|
24593
|
+
} catch (error51) {
|
|
24594
|
+
const rollback = [];
|
|
24595
|
+
if (binaryCommitted) {
|
|
24596
|
+
await fs.copyFile(previousBinary, installedBinary).catch((failure) => rollback.push(failure));
|
|
24597
|
+
await fs.chmod(installedBinary, 493).catch((failure) => rollback.push(failure));
|
|
24598
|
+
}
|
|
24599
|
+
if (viewerCommitted) {
|
|
24600
|
+
await fs.rm(viewer, { recursive: true, force: true }).catch((failure) => rollback.push(failure));
|
|
24601
|
+
}
|
|
24602
|
+
if (viewerBackedUp) {
|
|
24603
|
+
await fs.rename(previousViewer, viewer).catch((failure) => rollback.push(failure));
|
|
24604
|
+
}
|
|
24605
|
+
if (rollback.length > 0) {
|
|
24606
|
+
throw new AggregateError([error51, ...rollback], "Standalone update and rollback both failed.");
|
|
24607
|
+
}
|
|
24608
|
+
throw error51;
|
|
24609
|
+
} finally {
|
|
24610
|
+
await fs.rm(stagedViewer, { recursive: true, force: true }).catch(() => {
|
|
24611
|
+
return;
|
|
24612
|
+
});
|
|
24613
|
+
await fs.rm(stagedBinary, { force: true }).catch(() => {
|
|
24614
|
+
return;
|
|
24615
|
+
});
|
|
24616
|
+
}
|
|
24617
|
+
let settled = false;
|
|
24618
|
+
return Object.freeze({
|
|
24619
|
+
finalize: async () => {
|
|
24620
|
+
if (settled)
|
|
24621
|
+
return;
|
|
24622
|
+
settled = true;
|
|
24623
|
+
await fs.rm(previousViewer, { recursive: true, force: true }).catch(() => {
|
|
24624
|
+
return;
|
|
24625
|
+
});
|
|
24626
|
+
},
|
|
24627
|
+
rollback: async () => {
|
|
24628
|
+
if (settled)
|
|
24629
|
+
return;
|
|
24630
|
+
settled = true;
|
|
24631
|
+
const rollback = [];
|
|
24632
|
+
await fs.copyFile(previousBinary, installedBinary).catch((failure) => rollback.push(failure));
|
|
24633
|
+
await fs.chmod(installedBinary, 493).catch((failure) => rollback.push(failure));
|
|
24634
|
+
await fs.rm(viewer, { recursive: true, force: true }).catch((failure) => rollback.push(failure));
|
|
24635
|
+
if (viewerBackedUp) {
|
|
24636
|
+
await fs.rename(previousViewer, viewer).catch((failure) => rollback.push(failure));
|
|
24637
|
+
}
|
|
24638
|
+
if (rollback.length > 0) {
|
|
24639
|
+
throw new AggregateError(rollback, "Standalone update rollback failed.");
|
|
24640
|
+
}
|
|
24641
|
+
}
|
|
24642
|
+
});
|
|
24643
|
+
}
|
|
24644
|
+
var defaultUpdateDependencies = Object.freeze({ replaceStandaloneCohort, writeInstallMetadata });
|
|
24504
24645
|
|
|
24505
24646
|
// src/lib/admin-target.ts
|
|
24506
24647
|
var DEFAULT_ADMIN_TARGET_NAME = "admin";
|
|
@@ -24783,7 +24924,7 @@ function isManagedInstanceNotFound(error51) {
|
|
|
24783
24924
|
return error51 instanceof AdminInstanceNotFoundError || error51 instanceof AstraleError && error51.code === "INSTANCE_NOT_FOUND";
|
|
24784
24925
|
}
|
|
24785
24926
|
// src/lib/config.ts
|
|
24786
|
-
import { readFile as
|
|
24927
|
+
import { readFile as readFile7, writeFile as writeFile3, mkdir as mkdir6 } from "node:fs/promises";
|
|
24787
24928
|
var AstraleConfigSchema = exports_external.object({
|
|
24788
24929
|
issuer: exports_external.string().url().default("https://unregistered.invalid"),
|
|
24789
24930
|
admin: AdminTargetConfigSchema.default(DEFAULT_ADMIN_TARGET_CONFIG),
|
|
@@ -24792,7 +24933,7 @@ var AstraleConfigSchema = exports_external.object({
|
|
|
24792
24933
|
var DEFAULT_CONFIG = AstraleConfigSchema.parse({});
|
|
24793
24934
|
async function readConfig() {
|
|
24794
24935
|
try {
|
|
24795
|
-
const raw = await
|
|
24936
|
+
const raw = await readFile7(CONFIG_PATH, "utf-8");
|
|
24796
24937
|
return AstraleConfigSchema.parse(JSON.parse(raw));
|
|
24797
24938
|
} catch (e) {
|
|
24798
24939
|
if (e instanceof exports_external.ZodError || e instanceof SyntaxError) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { chmod, copyFile, mkdir, rename, rm } from 'node:fs/promises';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
import { AstraleError } from '../errors';
|
|
3
4
|
export declare const DEFAULT_UPDATE_CHANNEL = "beta";
|
|
@@ -83,9 +84,26 @@ export declare function readInstallMetadata(path?: string): Promise<InstallMetad
|
|
|
83
84
|
export declare function admitScriptInstall(meta: InstallMetadata, execution: Extract<UpdateExecution, {
|
|
84
85
|
kind: 'standalone';
|
|
85
86
|
}>): Promise<AdmittedScriptInstall>;
|
|
86
|
-
export declare function writeInstallMetadata(meta: InstallMetadata, path?: string): Promise<void>;
|
|
87
|
+
export declare function writeInstallMetadata(meta: InstallMetadata, path?: string, filesystem?: Pick<CohortFilesystem, 'mkdir' | 'rename' | 'rm'> & Pick<typeof import('node:fs/promises'), 'writeFile'>): Promise<void>;
|
|
87
88
|
export declare function releaseBase(meta: InstallMetadata, req: Pick<UpdateRequest, 'channel' | 'version'>): string;
|
|
88
89
|
export declare function fetchManifest(base: string): Promise<UpdateManifest>;
|
|
89
90
|
export declare function shouldUpdate(currentVersion: string, manifestVersion: string): boolean;
|
|
90
|
-
|
|
91
|
+
interface CohortFilesystem {
|
|
92
|
+
readonly chmod: typeof chmod;
|
|
93
|
+
readonly copyFile: typeof copyFile;
|
|
94
|
+
readonly mkdir: typeof mkdir;
|
|
95
|
+
readonly rename: typeof rename;
|
|
96
|
+
readonly rm: typeof rm;
|
|
97
|
+
}
|
|
98
|
+
export interface StandaloneCohortReplacement {
|
|
99
|
+
readonly finalize: () => Promise<void>;
|
|
100
|
+
readonly rollback: () => Promise<void>;
|
|
101
|
+
}
|
|
102
|
+
/** Replace the standalone executable and its Viewer as one rollback-safe cohort. */
|
|
103
|
+
export declare function replaceStandaloneCohort(installedBinary: string, nextBinary: string, nextViewerDist: string, filesystem?: Partial<CohortFilesystem>): Promise<StandaloneCohortReplacement>;
|
|
104
|
+
interface UpdateDependencies {
|
|
105
|
+
readonly replaceStandaloneCohort: typeof replaceStandaloneCohort;
|
|
106
|
+
readonly writeInstallMetadata: typeof writeInstallMetadata;
|
|
107
|
+
}
|
|
108
|
+
export declare function updateAstrale(req: UpdateRequest, dependencies?: Partial<UpdateDependencies>): Promise<UpdateResult>;
|
|
91
109
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@astrale-os/cli",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.20",
|
|
4
4
|
"description": "Astrale CLI — connect to existing Astrale kernels",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astrale",
|
|
@@ -62,8 +62,8 @@
|
|
|
62
62
|
},
|
|
63
63
|
"devDependencies": {
|
|
64
64
|
"@astrale-os/ox": ">=0.1.3 <1.0.0",
|
|
65
|
-
"@astrale-os/sdk": "0.5.0-beta.
|
|
66
|
-
"@astrale-os/shell": "0.4.2-beta.
|
|
65
|
+
"@astrale-os/sdk": "0.5.0-beta.50",
|
|
66
|
+
"@astrale-os/shell": "0.4.2-beta.5",
|
|
67
67
|
"@astrale/commitlint-config": "npm:@jsr/astrale__commitlint-config@~2.0.1",
|
|
68
68
|
"@commitlint/cli": "21.2.2",
|
|
69
69
|
"@commitlint/config-conventional": "21.2.2",
|
|
@@ -147,3 +147,52 @@ describe('view capture timing', () => {
|
|
|
147
147
|
expect(commands.length).toBeGreaterThan(2)
|
|
148
148
|
})
|
|
149
149
|
})
|
|
150
|
+
|
|
151
|
+
describe('view session runtime', () => {
|
|
152
|
+
test('builds one serve config with the admitted operator origin grant', async () => {
|
|
153
|
+
const { createViewServeConfig } = await import('../view')
|
|
154
|
+
const record = {
|
|
155
|
+
id: 'v-proof',
|
|
156
|
+
pid: 0,
|
|
157
|
+
port: 4419,
|
|
158
|
+
nonce: 'proof',
|
|
159
|
+
pageUrl: 'http://127.0.0.1:4419/s/proof/',
|
|
160
|
+
view: { target: Path.parse('/:ai-gateway.astrale.ai').raw, route: resolved[0] },
|
|
161
|
+
createdAt: '2026-08-26T00:00:00.000Z',
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const config = createViewServeConfig(
|
|
165
|
+
record,
|
|
166
|
+
{ allowExternalOrigin: ['https://connect.nango.dev/'] },
|
|
167
|
+
{ url: 'https://kernel.test', kernelIssuer: 'https://kernel.test' },
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
expect(config.session).toBe(record)
|
|
171
|
+
expect(config.externalOrigins).toEqual(['https://connect.nango.dev'])
|
|
172
|
+
expect(config.proxy).toEqual({
|
|
173
|
+
kernelUrl: 'https://kernel.test',
|
|
174
|
+
issuer: 'https://kernel.test',
|
|
175
|
+
caFile: undefined,
|
|
176
|
+
direct: true,
|
|
177
|
+
})
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
test('spawns the detached server from a compiled executable without its virtual Bun entry', async () => {
|
|
181
|
+
const { resolveServeRuntime, viewServeInvocation } = await import('../view')
|
|
182
|
+
|
|
183
|
+
const runtime = await resolveServeRuntime({
|
|
184
|
+
executable: '/opt/astrale/bin/astrale',
|
|
185
|
+
entry: '/$bunfs/root/astrale',
|
|
186
|
+
exists: () => true,
|
|
187
|
+
find: async () => '/usr/bin/node',
|
|
188
|
+
})
|
|
189
|
+
expect(runtime).toEqual({
|
|
190
|
+
file: '/opt/astrale/bin/astrale',
|
|
191
|
+
args: [],
|
|
192
|
+
})
|
|
193
|
+
expect(viewServeInvocation(runtime, '/tmp/view.config.json')).toEqual({
|
|
194
|
+
file: '/opt/astrale/bin/astrale',
|
|
195
|
+
args: ['__view-serve', '--config', '/tmp/view.config.json'],
|
|
196
|
+
})
|
|
197
|
+
})
|
|
198
|
+
})
|
package/src/commands/view.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { fatal, log } from '../lib/log'
|
|
|
19
19
|
import { isMachine, output, type RawOutputOpts } from '../lib/output'
|
|
20
20
|
import { findFreePort } from '../lib/port'
|
|
21
21
|
import { run, spawnHandle } from '../lib/proc'
|
|
22
|
+
import { admitExternalOpenOrigins } from '../lib/view/external-open-origins'
|
|
22
23
|
import { withViewPortAllocationLock } from '../lib/view/port-allocation'
|
|
23
24
|
import {
|
|
24
25
|
candidateSlug,
|
|
@@ -62,6 +63,7 @@ type ViewOpts = KernelCommandOpts &
|
|
|
62
63
|
sessions?: boolean
|
|
63
64
|
close?: string | boolean
|
|
64
65
|
all?: boolean
|
|
66
|
+
allowExternalOrigin?: string[]
|
|
65
67
|
}
|
|
66
68
|
|
|
67
69
|
const VIEW_PORT_BASE = 4419
|
|
@@ -151,16 +153,47 @@ async function chooseCandidate(
|
|
|
151
153
|
* die once the CLI exits. The published CLI entry is node-runnable; a dev
|
|
152
154
|
* checkout builds `dist/astrale.js` on demand (Bun is present there).
|
|
153
155
|
*/
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
156
|
+
interface ServeRuntimeEnvironment {
|
|
157
|
+
readonly entry: string | undefined
|
|
158
|
+
readonly executable: string
|
|
159
|
+
readonly exists: typeof existsSync
|
|
160
|
+
readonly find: typeof findOnPath
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function resolveServeRuntime(
|
|
164
|
+
environment: Partial<ServeRuntimeEnvironment> = {},
|
|
165
|
+
): Promise<{ file: string; args: string[] }> {
|
|
166
|
+
const entry = environment.entry ?? process.argv[1]
|
|
167
|
+
const executable = environment.executable ?? process.execPath
|
|
168
|
+
const exists = environment.exists ?? existsSync
|
|
169
|
+
const find = environment.find ?? findOnPath
|
|
170
|
+
const node = await find('node')
|
|
171
|
+
if (node && entry?.endsWith('.js') && exists(entry)) return { file: node, args: [entry] }
|
|
158
172
|
if (node && entry?.endsWith('.ts')) {
|
|
159
173
|
const dist = join(dirname(entry), '..', 'dist', 'astrale.js')
|
|
160
174
|
await ensureDevDist(entry, dist)
|
|
161
|
-
if (
|
|
175
|
+
if (exists(dist)) return { file: node, args: [dist] }
|
|
162
176
|
}
|
|
163
|
-
return
|
|
177
|
+
return directServeRuntime(executable, entry, entry !== undefined && exists(entry))
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Reinvoke a compiled executable without its virtual Bun filesystem entry. */
|
|
181
|
+
export function directServeRuntime(
|
|
182
|
+
executable: string,
|
|
183
|
+
entry: string | undefined,
|
|
184
|
+
entryExists = entry !== undefined && existsSync(entry),
|
|
185
|
+
): { file: string; args: string[] } {
|
|
186
|
+
return {
|
|
187
|
+
file: executable,
|
|
188
|
+
args: entry && !entry.startsWith('/$bunfs/') && entryExists ? [entry] : [],
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function viewServeInvocation(
|
|
193
|
+
runtime: { file: string; args: string[] },
|
|
194
|
+
config: string,
|
|
195
|
+
): { file: string; args: string[] } {
|
|
196
|
+
return { file: runtime.file, args: [...runtime.args, '__view-serve', '--config', config] }
|
|
164
197
|
}
|
|
165
198
|
|
|
166
199
|
async function findOnPath(name: string): Promise<string | null> {
|
|
@@ -229,6 +262,31 @@ async function startSession(view: ResolvedView, opts: ViewOpts): Promise<ViewSes
|
|
|
229
262
|
)
|
|
230
263
|
}
|
|
231
264
|
|
|
265
|
+
export function createViewServeConfig(
|
|
266
|
+
record: ViewSessionRecord,
|
|
267
|
+
opts: Pick<ViewOpts, 'allowExternalOrigin' | 'as' | 'creds' | 'instance' | 'timeout' | 'url'>,
|
|
268
|
+
kernelTarget: { url: string; kernelIssuer: string; caFile?: string },
|
|
269
|
+
): ViewServeConfig {
|
|
270
|
+
return {
|
|
271
|
+
session: record,
|
|
272
|
+
kernel: {
|
|
273
|
+
url: opts.url,
|
|
274
|
+
instance: opts.instance,
|
|
275
|
+
as: opts.as,
|
|
276
|
+
creds: opts.creds,
|
|
277
|
+
timeout: opts.timeout,
|
|
278
|
+
},
|
|
279
|
+
proxy: {
|
|
280
|
+
kernelUrl: kernelTarget.url,
|
|
281
|
+
issuer: kernelTarget.kernelIssuer,
|
|
282
|
+
caFile: kernelTarget.caFile,
|
|
283
|
+
direct: isPublicHttps(kernelTarget.url) && !kernelTarget.caFile,
|
|
284
|
+
},
|
|
285
|
+
externalOrigins: admitExternalOpenOrigins(opts.allowExternalOrigin),
|
|
286
|
+
idleMs: IDLE_MS,
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
232
290
|
/**
|
|
233
291
|
* Called under the cross-process port-allocation lock. Keep the lock until the
|
|
234
292
|
* detached child answers its readiness probe: only then is the selected port
|
|
@@ -262,34 +320,15 @@ async function startSessionLocked(
|
|
|
262
320
|
identity: opts.creds ? '(pre-signed creds)' : (opts.as ?? defaultIdentity),
|
|
263
321
|
createdAt: new Date().toISOString(),
|
|
264
322
|
}
|
|
265
|
-
const serveConfig
|
|
266
|
-
session: record,
|
|
267
|
-
kernel: {
|
|
268
|
-
url: opts.url,
|
|
269
|
-
instance: opts.instance,
|
|
270
|
-
as: opts.as,
|
|
271
|
-
creds: opts.creds,
|
|
272
|
-
timeout: opts.timeout,
|
|
273
|
-
},
|
|
274
|
-
proxy: {
|
|
275
|
-
kernelUrl: kernelTarget.url,
|
|
276
|
-
issuer: kernelTarget.kernelIssuer,
|
|
277
|
-
caFile: kernelTarget.caFile,
|
|
278
|
-
direct: isPublicHttps(kernelTarget.url) && !kernelTarget.caFile,
|
|
279
|
-
},
|
|
280
|
-
idleMs: IDLE_MS,
|
|
281
|
-
}
|
|
323
|
+
const serveConfig = createViewServeConfig(record, opts, kernelTarget)
|
|
282
324
|
|
|
283
325
|
await saveServeConfig(serveConfig)
|
|
284
326
|
const logFd = await openSessionLog(id)
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
stdio: ['ignore', logFd, logFd],
|
|
291
|
-
},
|
|
292
|
-
)
|
|
327
|
+
const invocation = viewServeInvocation(runtime, configPath(id))
|
|
328
|
+
const child = spawnHandle(invocation.file, invocation.args, {
|
|
329
|
+
detached: true,
|
|
330
|
+
stdio: ['ignore', logFd, logFd],
|
|
331
|
+
})
|
|
293
332
|
child.unref()
|
|
294
333
|
closeSync(logFd)
|
|
295
334
|
if (!child.pid) throw new Error('Failed to spawn the view session server')
|
|
@@ -521,6 +560,10 @@ export default {
|
|
|
521
560
|
description: 'Close a view session (bare: the only open one; with --all: every session)',
|
|
522
561
|
},
|
|
523
562
|
{ flags: '--all', description: 'With --close: close every session' },
|
|
563
|
+
{
|
|
564
|
+
flags: '--allow-external-origin <origin...>',
|
|
565
|
+
description: 'Grant this View exact HTTPS origins it may open in a new browser context',
|
|
566
|
+
},
|
|
524
567
|
],
|
|
525
568
|
afterHelpText: `
|
|
526
569
|
What it does:
|
|
@@ -539,6 +582,7 @@ Examples:
|
|
|
539
582
|
$ astrale view /:crm.example.dev:view.dashboard
|
|
540
583
|
$ astrale view /:agents.astrale.ai:view.agent --target @f00d1234 --as alice
|
|
541
584
|
$ astrale view @customer --snapshot
|
|
585
|
+
$ astrale view /:integrations.astrale.ai:view.application --allow-external-origin https://connect.nango.dev https://connect.composio.dev
|
|
542
586
|
$ astrale view --list
|
|
543
587
|
$ astrale view --sessions ; astrale view --close --all
|
|
544
588
|
`,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, expect, test } from 'bun:test'
|
|
2
|
-
import { chmod, mkdir, mkdtemp, readFile, symlink, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { chmod, mkdir, mkdtemp, readFile, rename, rm, symlink, writeFile } from 'node:fs/promises'
|
|
3
3
|
import { tmpdir } from 'node:os'
|
|
4
|
-
import { join } from 'node:path'
|
|
4
|
+
import { dirname, join } from 'node:path'
|
|
5
5
|
|
|
6
6
|
import {
|
|
7
7
|
admitScriptInstall,
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
InstallMetadataSchema,
|
|
11
11
|
readInstallMetadata,
|
|
12
12
|
releaseBase,
|
|
13
|
+
replaceStandaloneCohort,
|
|
13
14
|
shouldUpdate,
|
|
14
15
|
updateAstrale,
|
|
15
16
|
writeInstallMetadata,
|
|
@@ -20,7 +21,7 @@ import {
|
|
|
20
21
|
async function makeFakeRelease(
|
|
21
22
|
root: string,
|
|
22
23
|
version: string,
|
|
23
|
-
options: { binaryVersion?: string; legacyManifest?: boolean } = {},
|
|
24
|
+
options: { binaryVersion?: string; legacyManifest?: boolean; omitViewerIndex?: boolean } = {},
|
|
24
25
|
): Promise<string> {
|
|
25
26
|
const release = join(root, 'release')
|
|
26
27
|
const payload = join(root, 'payload')
|
|
@@ -32,9 +33,14 @@ async function makeFakeRelease(
|
|
|
32
33
|
`#!/usr/bin/env sh\nif [ "$1" = "--version" ]; then echo "${binaryVersion}"; exit 0; fi\necho astrale\n`,
|
|
33
34
|
)
|
|
34
35
|
await chmod(join(payload, 'astrale'), 0o755)
|
|
36
|
+
await mkdir(join(payload, 'viewer', 'dist'), { recursive: true })
|
|
37
|
+
await writeFile(join(payload, 'viewer', 'dist', 'main.js'), 'viewer main\n')
|
|
38
|
+
if (!options.omitViewerIndex) {
|
|
39
|
+
await writeFile(join(payload, 'viewer', 'dist', 'index.html'), '<!doctype html>\n')
|
|
40
|
+
}
|
|
35
41
|
|
|
36
42
|
const asset = join(release, 'astrale-darwin-arm64.tar.gz')
|
|
37
|
-
const tar = Bun.spawn(['tar', '-C', payload, '-czf', asset, 'astrale'])
|
|
43
|
+
const tar = Bun.spawn(['tar', '-C', payload, '-czf', asset, 'astrale', 'viewer'])
|
|
38
44
|
expect(await tar.exited).toBe(0)
|
|
39
45
|
const shaProc = Bun.spawn(['shasum', '-a', '256', asset], { stdout: 'pipe' })
|
|
40
46
|
const sha = (await new Response(shaProc.stdout).text()).trim().split(/\s+/)[0]
|
|
@@ -77,6 +83,9 @@ async function makeInstall(
|
|
|
77
83
|
`#!/usr/bin/env sh\nif [ "$1" = "--version" ]; then echo "${version}"; exit 0; fi\necho old\n`,
|
|
78
84
|
)
|
|
79
85
|
await chmod(bin, 0o755)
|
|
86
|
+
await mkdir(join(root, 'bin', 'viewer', 'dist'), { recursive: true })
|
|
87
|
+
await writeFile(join(root, 'bin', 'viewer', 'dist', 'main.js'), 'old viewer main\n')
|
|
88
|
+
await writeFile(join(root, 'bin', 'viewer', 'dist', 'index.html'), 'old viewer html\n')
|
|
80
89
|
const path = join(root, 'home', 'install.json')
|
|
81
90
|
const meta: InstallMetadata = {
|
|
82
91
|
method: 'script',
|
|
@@ -146,6 +155,39 @@ describe('update helpers', () => {
|
|
|
146
155
|
})
|
|
147
156
|
|
|
148
157
|
describe('script install admission', () => {
|
|
158
|
+
test('a metadata commit failure restores the exact previous file', async () => {
|
|
159
|
+
const root = await mkdtemp(join(tmpdir(), 'astrale-update-test-'))
|
|
160
|
+
const path = join(root, 'install.json')
|
|
161
|
+
const original = '{"exact":"previous metadata"}\n'
|
|
162
|
+
await writeFile(path, original)
|
|
163
|
+
|
|
164
|
+
await expect(
|
|
165
|
+
writeInstallMetadata(
|
|
166
|
+
{
|
|
167
|
+
method: 'script',
|
|
168
|
+
channel: 'beta',
|
|
169
|
+
version: '1.1.0',
|
|
170
|
+
repo: 'astrale-os/cli',
|
|
171
|
+
bin: '/tmp/astrale',
|
|
172
|
+
},
|
|
173
|
+
path,
|
|
174
|
+
{
|
|
175
|
+
mkdir,
|
|
176
|
+
rm,
|
|
177
|
+
writeFile,
|
|
178
|
+
rename: async (from, to) => {
|
|
179
|
+
if (String(from).endsWith('.next')) throw new Error('injected metadata commit failure')
|
|
180
|
+
await rename(from, to)
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
),
|
|
184
|
+
).rejects.toThrow('injected metadata commit failure')
|
|
185
|
+
|
|
186
|
+
expect(await readFile(path, 'utf8')).toBe(original)
|
|
187
|
+
await expect(readFile(`${path}.next`, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
|
188
|
+
await expect(readFile(`${path}.previous`, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' })
|
|
189
|
+
})
|
|
190
|
+
|
|
149
191
|
test('rejects malformed JSON with the stable metadata error', async () => {
|
|
150
192
|
const root = await mkdtemp(join(tmpdir(), 'astrale-update-test-'))
|
|
151
193
|
const path = join(root, 'install.json')
|
|
@@ -316,6 +358,124 @@ describe('updateAstrale', () => {
|
|
|
316
358
|
expect(await versionProc.exited).toBe(0)
|
|
317
359
|
const updatedMeta = JSON.parse(await readFile(path, 'utf8')) as InstallMetadata
|
|
318
360
|
expect(updatedMeta.version).toBe('1.1.0')
|
|
361
|
+
expect(await readFile(join(dirname(meta.bin), 'viewer', 'dist', 'main.js'), 'utf8')).toBe(
|
|
362
|
+
'viewer main\n',
|
|
363
|
+
)
|
|
364
|
+
expect(await readFile(join(dirname(meta.bin), 'viewer', 'dist', 'index.html'), 'utf8')).toBe(
|
|
365
|
+
'<!doctype html>\n',
|
|
366
|
+
)
|
|
367
|
+
} finally {
|
|
368
|
+
delete process.env.ASTRALE_UPDATE_BASE
|
|
369
|
+
}
|
|
370
|
+
})
|
|
371
|
+
|
|
372
|
+
test('a partial Viewer archive leaves the installed cohort and metadata unchanged', async () => {
|
|
373
|
+
const root = await mkdtemp(join(tmpdir(), 'astrale-update-test-'))
|
|
374
|
+
const { path, meta, execution } = await makeInstall(root, '1.0.0')
|
|
375
|
+
const release = await makeFakeRelease(root, '1.1.0', { omitViewerIndex: true })
|
|
376
|
+
process.env.ASTRALE_UPDATE_BASE = `file://${release}`
|
|
377
|
+
const beforeBinary = await readFile(meta.bin, 'utf8')
|
|
378
|
+
const beforeMetadata = await readFile(path, 'utf8')
|
|
379
|
+
try {
|
|
380
|
+
await expect(
|
|
381
|
+
updateAstrale({
|
|
382
|
+
currentVersion: '1.0.0',
|
|
383
|
+
platform: { os: 'darwin', arch: 'arm64' },
|
|
384
|
+
installPath: path,
|
|
385
|
+
execution,
|
|
386
|
+
}),
|
|
387
|
+
).rejects.toThrow()
|
|
388
|
+
|
|
389
|
+
expect(await readFile(meta.bin, 'utf8')).toBe(beforeBinary)
|
|
390
|
+
expect(await readFile(join(dirname(meta.bin), 'viewer', 'dist', 'main.js'), 'utf8')).toBe(
|
|
391
|
+
'old viewer main\n',
|
|
392
|
+
)
|
|
393
|
+
expect(await readFile(join(dirname(meta.bin), 'viewer', 'dist', 'index.html'), 'utf8')).toBe(
|
|
394
|
+
'old viewer html\n',
|
|
395
|
+
)
|
|
396
|
+
expect(await readFile(path, 'utf8')).toBe(beforeMetadata)
|
|
397
|
+
} finally {
|
|
398
|
+
delete process.env.ASTRALE_UPDATE_BASE
|
|
399
|
+
}
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test('a Viewer commit failure rolls back binary, Viewer, and metadata', async () => {
|
|
403
|
+
const root = await mkdtemp(join(tmpdir(), 'astrale-update-test-'))
|
|
404
|
+
const { path, meta, execution } = await makeInstall(root, '1.0.0')
|
|
405
|
+
const release = await makeFakeRelease(root, '1.1.0')
|
|
406
|
+
process.env.ASTRALE_UPDATE_BASE = `file://${release}`
|
|
407
|
+
const beforeBinary = await readFile(meta.bin, 'utf8')
|
|
408
|
+
const beforeMetadata = await readFile(path, 'utf8')
|
|
409
|
+
try {
|
|
410
|
+
await expect(
|
|
411
|
+
updateAstrale(
|
|
412
|
+
{
|
|
413
|
+
currentVersion: '1.0.0',
|
|
414
|
+
platform: { os: 'darwin', arch: 'arm64' },
|
|
415
|
+
installPath: path,
|
|
416
|
+
execution,
|
|
417
|
+
},
|
|
418
|
+
{
|
|
419
|
+
replaceStandaloneCohort: (installed, next, viewer) =>
|
|
420
|
+
replaceStandaloneCohort(installed, next, viewer, {
|
|
421
|
+
rename: async (from, to) => {
|
|
422
|
+
if (String(from).endsWith('viewer.next')) {
|
|
423
|
+
throw Object.assign(new Error('injected Viewer commit failure'), {
|
|
424
|
+
code: 'EIO',
|
|
425
|
+
})
|
|
426
|
+
}
|
|
427
|
+
await rename(from, to)
|
|
428
|
+
},
|
|
429
|
+
}),
|
|
430
|
+
},
|
|
431
|
+
),
|
|
432
|
+
).rejects.toThrow('injected Viewer commit failure')
|
|
433
|
+
|
|
434
|
+
expect(await readFile(meta.bin, 'utf8')).toBe(beforeBinary)
|
|
435
|
+
expect(await readFile(join(dirname(meta.bin), 'viewer', 'dist', 'main.js'), 'utf8')).toBe(
|
|
436
|
+
'old viewer main\n',
|
|
437
|
+
)
|
|
438
|
+
expect(await readFile(join(dirname(meta.bin), 'viewer', 'dist', 'index.html'), 'utf8')).toBe(
|
|
439
|
+
'old viewer html\n',
|
|
440
|
+
)
|
|
441
|
+
expect(await readFile(path, 'utf8')).toBe(beforeMetadata)
|
|
442
|
+
} finally {
|
|
443
|
+
delete process.env.ASTRALE_UPDATE_BASE
|
|
444
|
+
}
|
|
445
|
+
})
|
|
446
|
+
|
|
447
|
+
test('a metadata failure after cohort commit restores binary, Viewer, and metadata', async () => {
|
|
448
|
+
const root = await mkdtemp(join(tmpdir(), 'astrale-update-test-'))
|
|
449
|
+
const { path, meta, execution } = await makeInstall(root, '1.0.0')
|
|
450
|
+
const release = await makeFakeRelease(root, '1.1.0')
|
|
451
|
+
process.env.ASTRALE_UPDATE_BASE = `file://${release}`
|
|
452
|
+
const beforeBinary = await readFile(meta.bin, 'utf8')
|
|
453
|
+
const beforeMetadata = await readFile(path, 'utf8')
|
|
454
|
+
try {
|
|
455
|
+
await expect(
|
|
456
|
+
updateAstrale(
|
|
457
|
+
{
|
|
458
|
+
currentVersion: '1.0.0',
|
|
459
|
+
platform: { os: 'darwin', arch: 'arm64' },
|
|
460
|
+
installPath: path,
|
|
461
|
+
execution,
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
writeInstallMetadata: async () => {
|
|
465
|
+
throw new Error('injected metadata write failure')
|
|
466
|
+
},
|
|
467
|
+
},
|
|
468
|
+
),
|
|
469
|
+
).rejects.toThrow('injected metadata write failure')
|
|
470
|
+
|
|
471
|
+
expect(await readFile(meta.bin, 'utf8')).toBe(beforeBinary)
|
|
472
|
+
expect(await readFile(join(dirname(meta.bin), 'viewer', 'dist', 'main.js'), 'utf8')).toBe(
|
|
473
|
+
'old viewer main\n',
|
|
474
|
+
)
|
|
475
|
+
expect(await readFile(join(dirname(meta.bin), 'viewer', 'dist', 'index.html'), 'utf8')).toBe(
|
|
476
|
+
'old viewer html\n',
|
|
477
|
+
)
|
|
478
|
+
expect(await readFile(path, 'utf8')).toBe(beforeMetadata)
|
|
319
479
|
} finally {
|
|
320
480
|
delete process.env.ASTRALE_UPDATE_BASE
|
|
321
481
|
}
|
|
@@ -143,4 +143,18 @@ describe('viewer asset resolution', () => {
|
|
|
143
143
|
expect(stderr).toBe('')
|
|
144
144
|
expect(stdout.trim()).toBe(await realpath(viewer))
|
|
145
145
|
})
|
|
146
|
+
|
|
147
|
+
test('resolves assets shipped beside a Bun-compiled standalone executable', async () => {
|
|
148
|
+
const root = await mkdtemp(join(tmpdir(), 'astrale-view-standalone-'))
|
|
149
|
+
temporaryDirectories.push(root)
|
|
150
|
+
const executable = join(root, 'bin', 'astrale')
|
|
151
|
+
const viewer = join(root, 'bin', 'viewer', 'dist')
|
|
152
|
+
await mkdir(viewer, { recursive: true })
|
|
153
|
+
await writeFile(join(viewer, 'main.js'), '')
|
|
154
|
+
await writeFile(join(viewer, 'index.html'), '')
|
|
155
|
+
|
|
156
|
+
expect(viewerDistDir('file:///$bunfs/root/assets.ts', '/$bunfs/root/astrale', executable)).toBe(
|
|
157
|
+
viewer,
|
|
158
|
+
)
|
|
159
|
+
})
|
|
146
160
|
})
|