Skip to content

Commit 55fdde6

Browse files
authored
Merge pull request #34 from unicity-sphere/integration/all-fixes
chore: merge integration/all-fixes into main (50-commit rollup)
2 parents 28c878e + b64f85c commit 55fdde6

29 files changed

Lines changed: 6031 additions & 89 deletions

.github/workflows/ci.yml

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -39,21 +39,30 @@ jobs:
3939
- name: Clone sphere-sdk sibling
4040
# Pin to a specific commit SHA (not a branch name) for supply-chain
4141
# integrity — a branch pointer can be force-pushed or rebased,
42-
# silently changing the code CI builds against. This SHA points at
43-
# the tip of `refactor/extract-cli-to-sphere-cli` at the time of
44-
# this commit; that branch contains `bc07e89 feat(cli-extraction)`
45-
# which promoted CLI-consumed types (CreateInvoiceRequest,
46-
# PayInvoiceParams, encrypt/decrypt helpers, etc.) to the public
47-
# module surface. Those exports have not yet landed on `main` or
48-
# in any published npm version.
42+
# silently changing the code CI builds against.
43+
#
44+
# This SHA is the tip of sphere-sdk `main` ("merge: PR #395 #394
45+
# automated CID delivery re-enabled + 512 KiB inline cap + demo
46+
# playbook"). That commit exports the symbols `src/shared/sphere-
47+
# providers.ts` consumes from `@unicitylabs/sphere-sdk/impl/nodejs`:
48+
# `createUxfCarPublisher`, `DEFAULT_IPFS_GATEWAYS`,
49+
# `PublishToIpfsCallback`. It also exposes `AccountingModule.
50+
# deliverInvoice`, which `invoice-deliver` (PR #18 / issue #226)
51+
# calls. Pinning to `main` (rather than the previous integration-
52+
# branch tip 02cb4550) avoids the "unable to read tree" failure
53+
# when an integration tip is rebased away.
4954
#
5055
# Bump this SHA when a new sphere-sdk commit is required; remove
5156
# this whole workaround once sphere-sdk publishes v0.7.1+ to npm
5257
# with the post-extraction exports.
5358
env:
54-
SPHERE_SDK_SHA: 86468103ac25271b96a338f64349dd0eb472689f
59+
SPHERE_SDK_SHA: 3f3dadf9d03eb29db87f062921751f24bfefdec8
5560
run: |
5661
git clone https://github.com/unicity-sphere/sphere-sdk.git ../../sphere-sdk
62+
# The pinned SHA may not be on a branch tip after future merges;
63+
# fetch it explicitly before checkout so the workflow keeps
64+
# working when sphere-sdk main advances past this commit.
65+
git -C ../../sphere-sdk fetch origin "$SPHERE_SDK_SHA" || true
5766
git -C ../../sphere-sdk checkout --detach "$SPHERE_SDK_SHA"
5867
5968
- name: "Build sphere-sdk (required for file: dependency to resolve types)"

.github/workflows/integration-nightly.yml

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,11 +35,15 @@ jobs:
3535
cache: npm
3636

3737
# See ci.yml for the rationale behind the sibling-clone workaround.
38-
# Kept identical here so a nightly run is hermetic w.r.t. ci.yml state.
38+
# Kept identical here (same SHA pin) so a nightly run is hermetic
39+
# w.r.t. ci.yml state — bump both together when needed.
3940
- name: Clone sphere-sdk sibling
41+
env:
42+
SPHERE_SDK_SHA: 02cb4550facae0bea58c3b04aceaf3059599464b
4043
run: |
41-
git clone --depth 1 --branch refactor/extract-cli-to-sphere-cli \
42-
https://github.com/unicity-sphere/sphere-sdk.git ../../sphere-sdk
44+
git clone https://github.com/unicity-sphere/sphere-sdk.git ../../sphere-sdk
45+
git -C ../../sphere-sdk fetch origin "$SPHERE_SDK_SHA" || true
46+
git -C ../../sphere-sdk checkout --detach "$SPHERE_SDK_SHA"
4347
4448
- name: Build sphere-sdk (required for file: dependency to resolve types)
4549
run: |

src/host/sphere-init.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,11 @@
99

1010
import * as fs from 'node:fs';
1111
import { Sphere } from '@unicitylabs/sphere-sdk';
12-
import { createNodeProviders } from '@unicitylabs/sphere-sdk/impl/nodejs';
1312
import type { NetworkType } from '@unicitylabs/sphere-sdk';
13+
import {
14+
buildSphereProviders,
15+
detectWalletKind,
16+
} from '../shared/sphere-providers.js';
1417

1518
// All paths are CWD-relative by design — matches legacy-cli behaviour so the
1619
// same wallet is visible whether invoked via `sphere wallet …` (legacy) or
@@ -48,9 +51,25 @@ function loadConfig(): CliConfig {
4851
export async function initSphere(): Promise<Sphere> {
4952
const config = loadConfig();
5053

51-
const providers = createNodeProviders({
52-
network: config.network,
53-
dataDir: config.dataDir,
54+
// Issue #23 — same gate as the legacy CLI bootstrap. Host commands
55+
// cannot operate against a pre-migration wallet because the new
56+
// Profile-backed token storage would start empty and silently miss
57+
// every token the user has on the legacy IPNS-pointer path. Surface
58+
// the migration step explicitly instead of silently mis-routing.
59+
const kind = detectWalletKind(config.dataDir);
60+
if (kind === 'legacy') {
61+
throw new Error(
62+
`Legacy wallet detected at ${config.dataDir} (file storage + IPNS sync).\n` +
63+
'`sphere host` requires the new Profile storage. Migrate via:\n' +
64+
' sphere wallet migrate # dry-run summary first\n' +
65+
' sphere wallet migrate --apply # commit the import\n' +
66+
'See GitHub sphere-cli#23 for context.',
67+
);
68+
}
69+
70+
const providers = buildSphereProviders({
71+
network: config.network,
72+
dataDir: config.dataDir,
5473
tokensDir: config.tokensDir,
5574
});
5675

@@ -62,11 +81,16 @@ export async function initSphere(): Promise<Sphere> {
6281
}
6382

6483
const { sphere } = await Sphere.init({
65-
storage: providers.storage,
66-
transport: providers.transport,
67-
oracle: providers.oracle,
68-
network: config.network,
84+
storage: providers.storage,
85+
tokenStorage: providers.tokenStorage,
86+
transport: providers.transport,
87+
oracle: providers.oracle,
88+
network: config.network,
6989
autoGenerate: false,
90+
// sphere-sdk #394 — pass through the UXF CID-delivery wiring so
91+
// sends of > RELAY_SAFE_CAP_BYTES bundles can promote to CID.
92+
...(providers.publishToIpfs ? { publishToIpfs: providers.publishToIpfs } : {}),
93+
...(providers.cidFetchGateways ? { cidFetchGateways: providers.cidFetchGateways } : {}),
7094
});
7195

7296
return sphere;

src/index.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ describe('buildLegacyArgv dispatcher', () => {
9393
expect(buildLegacyArgv('wallet', ['create', 'foo', '--network', 'testnet']))
9494
.toEqual(['wallet', 'create', 'foo', '--network', 'testnet']);
9595
});
96+
// Issue #23 — `sphere wallet migrate` is the user-facing entry
97+
// point for moving a legacy IPNS-pointer wallet into the new
98+
// Profile storage. Lock the dispatch shape so a future namespace
99+
// refactor doesn't silently route it elsewhere.
100+
it('`wallet migrate` falls through to legacy `wallet migrate`', () => {
101+
expect(buildLegacyArgv('wallet', ['migrate'])).toEqual(['wallet', 'migrate']);
102+
});
103+
it('`wallet migrate --apply` preserves the apply flag', () => {
104+
expect(buildLegacyArgv('wallet', ['migrate', '--apply']))
105+
.toEqual(['wallet', 'migrate', '--apply']);
106+
});
96107
it('bare `wallet` with no subcommand produces `[wallet]`', () => {
97108
expect(buildLegacyArgv('wallet', [])).toEqual(['wallet']);
98109
});

src/legacy/daemon.ts

Lines changed: 61 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ interface PidFileData {
4242
* Parse a PID file. Handles both the new JSON format and the legacy plain-text
4343
* format (just a number). Returns null on parse failure or missing file.
4444
*/
45-
function readPidFile(pidFile: string): PidFileData | null {
45+
export function readPidFile(pidFile: string): PidFileData | null {
4646
let raw: string;
4747
try {
4848
raw = fs.readFileSync(pidFile, 'utf8').trim();
@@ -81,7 +81,7 @@ function readPidFile(pidFile: string): PidFileData | null {
8181
* Returns false for dead PIDs and for PIDs that are alive but clearly not ours
8282
* (i.e. PID reuse case).
8383
*/
84-
function isDaemonProcessAlive(pid: number): boolean {
84+
export function isDaemonProcessAlive(pid: number): boolean {
8585
if (!isProcessAlive(pid)) return false;
8686
// Best-effort PID reuse detection via /proc/<pid>/comm (Linux only).
8787
try {
@@ -442,9 +442,13 @@ let verboseMode = false;
442442
function log(message: string): void {
443443
const line = message.startsWith('[') ? message : `[${new Date().toISOString()}] ${message}`;
444444
if (logStream) {
445+
// In forked mode the WriteStream IS the log destination — avoid
446+
// double-writing via the (now-redirected) console.log which also
447+
// forwards to the same stream.
445448
logStream.write(line + '\n');
449+
} else {
450+
console.log(line);
446451
}
447-
console.log(line);
448452
}
449453

450454
// =============================================================================
@@ -569,8 +573,23 @@ export async function runDaemon(
569573
throw e;
570574
}
571575

572-
// Disconnect from parent
573-
if (process.disconnect) process.disconnect();
576+
// Disconnect from parent's IPC channel if one exists. The parent's
577+
// detachDaemon call passes 'ipc' in stdio (Fix issue #19) so the channel
578+
// is normally open here; the `process.connected` guard handles the
579+
// edge case of running with a non-IPC stdio (test harnesses, manual
580+
// invocation of `daemon start --_forked`). Calling `process.disconnect()`
581+
// without a live channel throws "IPC channel is not open", which under
582+
// `stdio: 'ignore'` would crash the child silently with no log trail.
583+
//
584+
// The try/catch handles a residual race: the parent's child.disconnect()
585+
// closes the channel at the OS layer, but the JS 'disconnect' event
586+
// (which flips process.connected to false) is async — there's a microtask
587+
// window where process.connected reads true while the underlying channel
588+
// is already torn down, in which case disconnect() throws. Swallowing
589+
// here is correct: the goal state (channel closed) already holds.
590+
if (process.connected && process.disconnect) {
591+
try { process.disconnect(); } catch { /* already torn down by parent */ }
592+
}
574593

575594
// Restore on exit for cleanup logging
576595
process.on('exit', () => {
@@ -697,14 +716,21 @@ export async function runDaemon(
697716
// =============================================================================
698717

699718
function detachDaemon(args: string[], flags: DaemonFlags): void {
700-
// Build the resolved config just to get the PID file path
719+
// Resolve config once so we have BOTH the PID file path (for the advisory
720+
// already-running check below) AND the log file path (so we can open it in
721+
// the parent and inherit the fd into the child — see fork() call below).
701722
let pidFile: string;
723+
let logFile: string;
702724
try {
703725
const rawConfig = buildConfigFromFlags(flags);
704726
const config = resolveConfig(rawConfig);
705727
pidFile = config.pidFile;
728+
logFile = config.logFile;
706729
} catch {
707730
pidFile = getDefaultPidFile();
731+
// Match the default emitted by the success message below so the operator
732+
// sees a consistent path. resolveConfig() would normally produce this.
733+
logFile = '.sphere-cli/daemon.log';
708734
}
709735

710736
// Check if already running. Note: this check is advisory only — the forked
@@ -723,22 +749,43 @@ function detachDaemon(args: string[], flags: DaemonFlags): void {
723749
// Build child args: replace --detach with --_forked, keep everything else
724750
const childArgs = ['daemon', 'start', '--_forked', ...args.filter(a => a !== '--detach')];
725751

726-
// Fork the child process
752+
// Fix issue #19: Open the log file in the parent and pass its fd as the
753+
// child's stdout and stderr. The previous `stdio: 'ignore'` discarded both
754+
// streams, so any failure between fork() and the child's first WriteStream
755+
// flush — including the silent crash from `process.disconnect()` throwing
756+
// on a missing IPC channel — was invisible: no log, no PID-file cleanup,
757+
// just a stale pid. With the fd inherited at OS level, any thrown error,
758+
// uncaught exception, or stderr emission lands in the log file before any
759+
// Node-level streaming machinery is required.
760+
//
761+
// The 'ipc' entry is required by child_process.fork() (Node throws
762+
// "Forked processes must have an IPC channel" without it). The parent
763+
// does not send messages over the channel — it exists solely to satisfy
764+
// fork's contract. The child's runDaemon() calls process.disconnect()
765+
// (guarded on process.connected) to release the channel from the child's
766+
// event loop after PID-file write.
767+
ensureDir(logFile);
768+
const logFd = fs.openSync(logFile, 'a');
769+
fs.writeSync(
770+
logFd,
771+
`[${new Date().toISOString()}] sphere daemon: forking child (parent PID ${process.pid})\n`,
772+
);
727773
const child = fork(process.argv[1], childArgs, {
728774
detached: true,
729-
stdio: 'ignore',
775+
stdio: ['ignore', logFd, logFd, 'ipc'],
730776
});
777+
// Child has inherited its own copy of the fd; close the parent's reference.
778+
fs.closeSync(logFd);
731779

780+
// Don't keep the parent alive waiting for the child. We also disconnect
781+
// the parent's end of the IPC channel so process.exit(0) below doesn't
782+
// need to forcibly tear down a live handle.
732783
child.unref();
784+
if (child.connected) child.disconnect();
733785

734786
console.log(`Daemon started in background (PID ${child.pid})`);
735787
console.log(`PID file: ${pidFile}`);
736-
737-
if (flags.logFile) {
738-
console.log(`Log file: ${flags.logFile}`);
739-
} else {
740-
console.log('Log file: .sphere-cli/daemon.log');
741-
}
788+
console.log(`Log file: ${logFile}`);
742789

743790
process.exit(0);
744791
}

0 commit comments

Comments
 (0)