Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .changeset/30106-install-custom-pair-into-writable-pair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@objectstack/cloud-connection': patch
'@objectstack/cli': patch
---

fix(cloud-connection,cli): an exported `<base>.custom` installed on another environment lands in that environment's writable pair, and its overrides are served (ADR-0146 D2 / D3)

Clause-②: no
Metadata-protocol: none

- **Before.** `os package install <export>/objectstack.json` of a base's paired customization package answered success and printed "Package installed into the running kernel", but the install registered the pair as code. None of its overrides was served, `GET /packages/<base>/customizations` listed none of them, and the pair turned read-only, so `ensure-customization` answered `writable: false` and every further save into it was refused `WRITABLE_PACKAGE_REQUIRED`.
- **Now.** `POST /api/v1/marketplace/install-local` installs a pair as what it is. The target's own pair is answered by its ensure door, which creates it when absent. Every item of the body is then written into it through the save door a Studio edit takes, with `mode: 'publish'` and the installer as actor, in load order. The overrides are served at once, the customization read lists them, and the pair stays editable. Nothing is registered as code or written to the cached-manifest directory. The answer carries `customizationPair` (`basePackageId`, `packageId`, `created`, `writable`), `writtenCount` and `written`, and the CLI prints them.
- **Refusals, nothing written.** A body key with no item form (code, a `translations` bundle, a key the install-key table does not classify), a nameless item, or two items claiming one name: `422`, every problem listed. A pair manifest the ensure door would not write: `400 PLUGIN_MANIFEST_INVALID`. A pair inside a multi-package artifact: `422`. A pair already read-only on the target: `409 RESOURCE_CONFLICT`, asked before the ensure door. If an earlier install registered it as code, the message names the uninstall and the restart that clear it. The ensure door's own refusal is relayed with its status and code.
- **A save refused part-way.** The answer is a failure with the save's code and status, naming the item and where it sits in the body. A pair this install created is deleted again. A pair that existed before is kept, and the answer lists the items written and the ones not written.
- **The CLI.** A runtime that predates this answers a pair as a managed install. The CLI now prints that as an error with the way back (uninstall, upgrade and restart, install again) and exits 1, instead of printing success.
50 changes: 49 additions & 1 deletion packages/cli/src/commands/package/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@
* "Source" and "mode" are two words for two questions on purpose: where the
* package comes from, and how it is installed.
*
* A base's paired customization package — `<base>.custom`, what
* `os package export` writes for the overrides an administrator authored
* (ADR-0146 D2 / D3) — is neither: the runtime writes its items into ITS OWN
* writable pair of that base, as Studio would, so the overrides are served,
* listed by the base's customization read, and stay editable there. Printed
* as such; a runtime that answers it as a managed install (one that predates
* the pair install, and registered it as read-only code) is printed as the
* failure it is, with the way back.
*
* This complements `os package publish` (which uploads to the CLOUD): the
* pairing is publish-to-cloud / install-into-runtime. The target runtime
* authenticates the call with its own better-auth session — pass
Expand All @@ -43,7 +52,7 @@ import { readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { resolve as resolvePath } from 'node:path';
import { Args, Command, Flags } from '@oclif/core';
import { INSTALL_MODES, type InstallMode } from '@objectstack/spec/kernel';
import { INSTALL_MODES, customizationBasePackageId, type InstallMode } from '@objectstack/spec/kernel';
import { printHeader, printKV, printSuccess, printWarning, printError, printStep, isExitSignal } from '../../utils/format.js';

/**
Expand Down Expand Up @@ -306,6 +315,45 @@ export default class PackageInstall extends Command {

const data = res.body?.data ?? res.body ?? {};

// [ADR-0146 D3, #30106] A base's paired customization package answers
// its own shape too: the writable pair its items were written into,
// never a kernel registration or a cached manifest — so, as for an
// unmanaged install, none of the managed lines below is true of it.
const pair = data.customizationPair;
if (pair && typeof pair === 'object' && typeof pair.packageId === 'string') {
console.log('');
printSuccess(
`Customization package installed into the writable pair of '${String(pair.basePackageId)}' — `
+ 'its overrides are served now, and the pair stays editable on this runtime',
);
printKV(' Package', String(data.manifestId ?? label));
printKV(' Version', String(data.version ?? flags.version));
printKV(' Runtime', runtime);
printKV(' Pair', `${pair.packageId}${pair.created === true ? ' (created by this install)' : ''}`);
if (typeof data.writtenCount === 'number') printKV(' Items written', String(data.writtenCount));
if (data.seeded?.mode === 'skipped' && typeof data.seeded.reason === 'string') {
printKV(' Sample data', `not loaded — ${data.seeded.reason}`);
}
printWithheldMaps(data.withheldMaps);
console.log('');
return;
}
// Sent a pair, answered as a managed install: the runtime predates the
// pair install and registered the pair as CODE — read-only, serving none
// of its overrides. Said out loud, never printed as a success: that
// answer is what #30106 measured.
if (customizationBasePackageId(String(data.manifestId ?? label)) !== null) {
const pairId = String(data.manifestId ?? label);
printError(
`The runtime at ${runtime} installed ${pairId} as code, not into its writable pair: it serves none of the `
+ 'package\'s overrides and leaves the pair read-only. Uninstall it '
+ `(\`DELETE ${runtime}/api/v1/marketplace/install-local/${pairId}\`), upgrade and restart the runtime, `
+ 'then install it again.',
);
this.exit(1);
return;
}

// [ADR-0131 D6] An unmanaged install answers its own shape: what was
// copied and where, never a kernel registration — so none of the
// managed lines below (hot-load, the cached-manifest directory that
Expand Down
8 changes: 7 additions & 1 deletion packages/cli/test/package-export.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,13 @@ describe('os package export', () => {
const posted: any[] = [];
vi.stubGlobal('fetch', vi.fn(async (_url: string, init: any) => {
posted.push(JSON.parse(init.body));
return new Response(JSON.stringify({ success: true, data: { manifestId: entry.id, version: entry.version, hotLoaded: true } }), { status: 200 });
// [ADR-0146 D3] A pair is answered as the runtime installs it: into
// its writable pair, never as code (which the command prints as the
// failure it is).
const answered = entry === PAIR
? { manifestId: entry.id, version: entry.version, customizationPair: { basePackageId: BASE.id, packageId: entry.id, created: true, writable: true }, writtenCount: 0, written: [] }
: { manifestId: entry.id, version: entry.version, hotLoaded: true };
return new Response(JSON.stringify({ success: true, data: answered }), { status: 200 });
}));
const file = join(out, exportedPackageDirectory(i, 3, entry.id), EXPORTED_PACKAGE_FILE);
const r = await run(PackageInstall, [file, '--runtime', 'http://target.test']);
Expand Down
104 changes: 104 additions & 0 deletions packages/cli/test/package-install-customization-pair.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [ADR-0146 D3] `os package install` of a base's paired customization
* package (`BASE.custom`, what `os package export` writes for the overrides
* an administrator authored).
*
* The runtime writes the pair's items into its own writable pair and answers
* `customizationPair`. Before, a pair was registered as code, and this
* command printed "Package installed into the running kernel" while none of
* its overrides was served. Pinned:
* - an answer carrying `customizationPair` prints the pair it wrote into and
* the items written, and none of the managed install's lines;
* - a pair answered as a managed install (a runtime that predates the pair
* install, which registered it as code) prints an error naming the way
* back, and exits 1, never success.
*/

import { describe, it, expect, afterEach, vi } from 'vitest';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import PackageInstall from '../src/commands/package/install.js';

const PAIR = 'com.example.showcase.custom';

/** Stub the runtime's install POST with the given `data` block. */
function stubRuntime(data: Record<string, unknown>): void {
vi.stubGlobal('fetch', vi.fn(async () => ({
ok: true,
status: 200,
statusText: 'OK',
headers: { get: () => null },
json: async () => ({ success: true, data }),
}) as any));
}

/** Run the command; return everything it printed and how it exited. */
async function runInstall(args: string[]): Promise<{ out: string; exit: number | undefined }> {
const lines: string[] = [];
const capture = (...a: unknown[]) => { lines.push(a.map(String).join(' ')); };
vi.spyOn(console, 'log').mockImplementation(capture);
vi.spyOn(console, 'error').mockImplementation(capture);
let exit: number | undefined;
try {
await PackageInstall.run([...args, '--runtime', 'http://runtime.test']);
} catch (err: any) {
exit = err?.oclif?.exit ?? err?.code ?? 1;
}
return { out: lines.join('\n'), exit };
}

/** The pair's `objectstack.json`, as `os package export` writes it. */
function writePairExport(): { file: string; dir: string } {
const dir = mkdtempSync(join(tmpdir(), 'os-install-pair-'));
const file = join(dir, 'objectstack.json');
writeFileSync(file, JSON.stringify({
viewItems: [{ name: 'showcase_task.in_progress', label: 'Ours', overrides: { package: 'com.example.showcase', basedOn: '0.1.0' } }],
id: PAIR, name: 'Showcase (custom)', version: '0.1.0', namespace: 'showcase_custom',
dependencies: { 'com.example.showcase': '^0' },
}));
return { file, dir };
}

let dir: string | undefined;
afterEach(() => {
if (dir) rmSync(dir, { recursive: true, force: true });
dir = undefined;
vi.unstubAllGlobals();
vi.restoreAllMocks();
});

describe('os package install — a paired customization package', () => {
it('prints the writable pair its items were written into, and none of the managed lines', async () => {
const exported = writePairExport();
dir = exported.dir;
stubRuntime({
manifestId: PAIR, version: '0.1.0', versionId: 'local', installedAt: '2026-11-05T00:00:00.000Z',
customizationPair: { basePackageId: 'com.example.showcase', packageId: PAIR, created: true, writable: true },
writtenCount: 1, written: [{ type: 'view', name: 'showcase_task.in_progress' }],
});
const { out, exit } = await runInstall([exported.file]);
expect(exit).toBeUndefined();
expect(out).toContain("Customization package installed into the writable pair of 'com.example.showcase'");
expect(out).toContain(`${PAIR} (created by this install)`);
expect(out).toMatch(/Items written:?\s+1/);
expect(out).not.toContain('running kernel');
expect(out).not.toContain('installed-packages');
});

it('prints a pair answered as a managed install as the failure it is, and exits 1', async () => {
const exported = writePairExport();
dir = exported.dir;
stubRuntime({
manifestId: PAIR, version: '0.1.0', versionId: 'local', installedAt: '2026-11-05T00:00:00.000Z',
hotLoaded: true, storageDir: '/srv/runtime/.objectstack/installed-packages',
});
const { out, exit } = await runInstall([exported.file]);
expect(exit).toBe(1);
expect(out).toContain(`installed ${PAIR} as code, not into its writable pair`);
expect(out).toContain(`DELETE http://runtime.test/api/v1/marketplace/install-local/${PAIR}`);
expect(out).not.toContain('Package installed into the running kernel');
});
});
Loading
Loading