@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.
@@ -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 readFile6, writeFile as writeFile2, mkdir as mkdir5 } from "node:fs/promises";
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 readFile6(CONFIG_PATH, "utf-8");
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
- export declare function updateAstrale(req: UpdateRequest): Promise<UpdateResult>;
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.19",
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.49",
66
- "@astrale-os/shell": "0.4.2-beta.3",
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
+ })
@@ -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
- async function resolveServeRuntime(): Promise<{ file: string; args: string[] }> {
155
- const entry = process.argv[1]
156
- const node = await findOnPath('node')
157
- if (node && entry?.endsWith('.js') && existsSync(entry)) return { file: node, args: [entry] }
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 (existsSync(dist)) return { file: node, args: [dist] }
175
+ if (exists(dist)) return { file: node, args: [dist] }
162
176
  }
163
- return { file: process.execPath, args: entry && existsSync(entry) ? [entry] : [] }
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: ViewServeConfig = {
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 child = spawnHandle(
286
- runtime.file,
287
- [...runtime.args, '__view-serve', '--config', configPath(id)],
288
- {
289
- detached: true,
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
  })