Skip to content

Commit c118311

Browse files
committed
feat: add multi-project support for artifact binding and CLI commands
1 parent 9733df2 commit c118311

5 files changed

Lines changed: 198 additions & 10 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### Added
11+
- **`os auth login` / `register` / `me` now work against multi-project servers**`@objectstack/client` was sending requests to better-auth's `/sign-in/email`, `/sign-up/email`, `/sign-out`, `/get-session` without an `Origin` header, which better-auth rejected with `MISSING_OR_NULL_ORIGIN: Missing or null Origin` against the default `trustedOrigins: ['http://localhost:*']`. Auth methods now send `Origin: <baseUrl>` automatically. Additionally, the `login()` and `register()` response normalizer now accepts both the wrapped `{ data: { token, user } }` shape and better-auth's flat `{ token, user }` shape so the CLI's `auth login` flow stores the token correctly.
12+
- **`os projects bind` + `os projects create --artifact` CLI commands** — Third-party developers can now bind a locally-compiled bundle to a multi-project server without raw `curl`. `os projects create --org <id> --name <name> --artifact ./dist/objectstack.json` provisions a new project and seeds the bundle in one call (also supports `--template <id>` for the parity with built-in templates). The new `os projects bind <projectId> --artifact <path>` updates an existing project's `metadata.artifact_path`, with `--build` to run `objectstack compile` first and `--reseed` as a placeholder for the future server-side reseed endpoint. Both flags resolve relative paths to absolute and validate the file exists before issuing the API call. Verified end-to-end: project created via CLI, `/api/v1/projects/{id}/data/account` returns the seeded CRM accounts.
1113
- **Third-party project binding via `metadata.artifact_path`** — Multi-project `POST /api/v1/cloud/projects` now accepts `metadata.artifact_path` to bind a locally-compiled bundle (e.g. `examples/app-crm/dist/objectstack.json`) into a fresh project. Provisioning loads the JSON, registers schemas in the per-project ObjectQL engine, and seeds the bundle's `data` arrays — same pipeline that built-in templates use. New `TemplateSeeder.seedBundle({ projectId, bundle })` method exposes the seeder for arbitrary bundles. Bind errors (read failure, malformed JSON) are recorded as non-fatal `metadata.artifactBindError` so the project still flips to `active`. Verified end-to-end: query `/api/v1/projects/{id}/data/account` returns the seeded CRM accounts.
1214
- **`AppBundleResolver` for live kernel binding**`apps/server` now ships `createFsAppBundleResolver()` in `apps/server/server/fs-app-bundle-resolver.ts`. Reads `sys_project.metadata.artifact_path` (or `artifact_paths[]`) at per-project kernel boot, with optional `OBJECTSTACK_PROJECT_ARTIFACTS=projId:path,…` env override and `OBJECTSTACK_PROJECT_ARTIFACT_ROOT` for relative path resolution. Used wherever a project kernel is materialized (trigger / function / metadata API requests). Replaces the previous `return []` stub.
1315

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { Command, Flags, Args } from '@oclif/core';
4+
import path from 'node:path';
5+
import fs from 'node:fs/promises';
6+
import { spawnSync } from 'node:child_process';
7+
import { printError, printStep, printKV } from '../../utils/format.js';
8+
import { createApiClient, requireAuth } from '../../utils/api-client.js';
9+
import { formatOutput } from '../../utils/output-formatter.js';
10+
11+
/**
12+
* `os projects bind` — bind a locally-compiled artifact to an existing
13+
* multi-project server project.
14+
*
15+
* Equivalent to `PATCH /api/v1/cloud/projects/<id>` with
16+
* `metadata.artifact_path = <absolute-path>`. The server's
17+
* AppBundleResolver picks up the path on the next per-project kernel
18+
* boot, registering the bundle's objects, views, and seed data.
19+
*
20+
* Use `--build` to compile `objectstack.config.ts` first so the artifact
21+
* reflects the latest source.
22+
*/
23+
export default class ProjectsBind extends Command {
24+
static override description = 'Bind a local objectstack artifact to an existing project';
25+
26+
static override examples = [
27+
'$ os projects bind <project-id> --artifact ./dist/objectstack.json',
28+
'$ os projects bind <project-id> --artifact ./dist/objectstack.json --build',
29+
'$ os projects bind <project-id> --reseed',
30+
];
31+
32+
static override args = {
33+
projectId: Args.string({
34+
description: 'Target project id (UUID)',
35+
required: true,
36+
}),
37+
};
38+
39+
static override flags = {
40+
url: Flags.string({ char: 'u', description: 'Server URL', env: 'OBJECTSTACK_URL' }),
41+
token: Flags.string({ char: 't', description: 'Authentication token', env: 'OBJECTSTACK_TOKEN' }),
42+
artifact: Flags.string({
43+
description: 'Path to a compiled objectstack.json artifact (default: ./dist/objectstack.json)',
44+
}),
45+
build: Flags.boolean({
46+
description: 'Run `objectstack compile` before binding',
47+
default: false,
48+
}),
49+
reseed: Flags.boolean({
50+
description: 'After binding, also re-run schema sync + bundle seeding via /cloud/projects/:id/reseed',
51+
default: false,
52+
}),
53+
format: Flags.string({
54+
char: 'f',
55+
description: 'Output format',
56+
options: ['json', 'table', 'yaml'],
57+
default: 'table',
58+
}),
59+
};
60+
61+
async run(): Promise<void> {
62+
const { args, flags } = await this.parse(ProjectsBind);
63+
64+
try {
65+
const artifactRel = flags.artifact ?? './dist/objectstack.json';
66+
const artifactAbs = path.isAbsolute(artifactRel)
67+
? artifactRel
68+
: path.resolve(process.cwd(), artifactRel);
69+
70+
if (flags.build) {
71+
printStep('Compiling objectstack.config.ts → ' + artifactAbs);
72+
const binPath = process.argv[1];
73+
const r = spawnSync(
74+
process.execPath,
75+
[binPath, 'compile', '--output', artifactAbs],
76+
{ stdio: 'inherit', env: { ...process.env, NODE_ENV: 'development' } },
77+
);
78+
if (r.status !== 0) {
79+
printError('Compile failed — fix errors above before binding');
80+
this.exit(1);
81+
}
82+
}
83+
84+
try {
85+
await fs.access(artifactAbs);
86+
} catch {
87+
printError(`Artifact not found: ${artifactAbs}`);
88+
if (!flags.build) {
89+
console.error(' Hint: pass --build to compile first, or check the path with --artifact.');
90+
}
91+
this.exit(1);
92+
}
93+
94+
const { client, token } = await createApiClient({ url: flags.url, token: flags.token });
95+
requireAuth(token);
96+
97+
// Fetch existing metadata so we don't blow it away.
98+
const current = await client.projects.get(args.projectId);
99+
const existingMeta: Record<string, unknown> = (current?.project?.metadata && typeof current.project.metadata === 'object')
100+
? { ...current.project.metadata as Record<string, unknown> }
101+
: {};
102+
// Drop the prior bind error so the UI doesn't show a stale failure.
103+
delete existingMeta.artifactBindError;
104+
existingMeta.artifact_path = artifactAbs;
105+
106+
printKV('Project', args.projectId, '🎯');
107+
printKV('Artifact', artifactAbs, '📦');
108+
109+
const res = await client.projects.update(args.projectId, {
110+
metadata: existingMeta,
111+
});
112+
113+
if (flags.reseed) {
114+
printStep('Reseeding bundle (POST /cloud/projects/:id/reseed)…');
115+
try {
116+
// Best-effort: server may not expose a reseed endpoint yet.
117+
const reseed = await (client as any).fetch?.(
118+
`${(client as any).baseUrl}/api/v1/cloud/projects/${encodeURIComponent(args.projectId)}/reseed`,
119+
{ method: 'POST' },
120+
);
121+
if (reseed && typeof reseed.ok === 'boolean' && !reseed.ok) {
122+
console.error(' ⚠ reseed endpoint returned ' + reseed.status + ' — bundle will be applied on next kernel boot.');
123+
}
124+
} catch (e: any) {
125+
console.error(' ⚠ reseed failed: ' + (e?.message ?? e) + ' — bundle will be applied on next kernel boot.');
126+
}
127+
}
128+
129+
if (flags.format === 'json') {
130+
formatOutput(res, 'json');
131+
} else if (flags.format === 'yaml') {
132+
formatOutput(res, 'yaml');
133+
} else {
134+
console.log(`\n✓ Project bound to artifact`);
135+
console.log(` ${args.projectId}${artifactAbs}`);
136+
console.log(` The next request to this project will load the new bundle.`);
137+
console.log('');
138+
}
139+
} catch (error: any) {
140+
if (flags.format === 'json') {
141+
console.log(JSON.stringify({ success: false, error: error.message }, null, 2));
142+
this.exit(1);
143+
}
144+
printError(error.message || String(error));
145+
this.exit(1);
146+
}
147+
}
148+
}

packages/cli/src/commands/projects/create.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export default class ProjectsCreate extends Command {
2121
'$ os projects create --org 00000000-0000-0000-0000-000000000000 --name Staging',
2222
'$ os projects create --org $ORG --name Dev --plan free',
2323
'$ os projects create --org $ORG --name "Clone" --clone-from <source-id> --no-activate',
24+
'$ os projects create --org $ORG --name CRM --artifact ./examples/app-crm/dist/objectstack.json',
2425
];
2526

2627
static override flags = {
@@ -30,6 +31,10 @@ export default class ProjectsCreate extends Command {
3031
name: Flags.string({ description: 'Display name', required: true }),
3132
plan: Flags.string({ description: 'Billing plan', default: 'free' }),
3233
driver: Flags.string({ description: 'Data-plane driver id' }),
34+
template: Flags.string({ description: 'Built-in template id (e.g. crm, todo, blank)' }),
35+
artifact: Flags.string({
36+
description: 'Path to a locally-compiled objectstack.json artifact to bind into this project',
37+
}),
3338
'clone-from': Flags.string({ description: 'Clone schema from an existing project id' }),
3439
activate: Flags.boolean({
3540
description: 'Activate the new project for subsequent CLI calls',
@@ -51,12 +56,33 @@ export default class ProjectsCreate extends Command {
5156
const { client, token } = await createApiClient({ url: flags.url, token: flags.token });
5257
requireAuth(token);
5358

59+
// Resolve the artifact to an absolute path so the server can read it
60+
// regardless of its CWD. Bail early if the file is missing — better
61+
// to fail before provisioning than leave a half-bound project.
62+
let metadata: Record<string, unknown> | undefined;
63+
if (flags.artifact) {
64+
const path = await import('node:path');
65+
const fs = await import('node:fs/promises');
66+
const abs = path.isAbsolute(flags.artifact)
67+
? flags.artifact
68+
: path.resolve(process.cwd(), flags.artifact);
69+
try {
70+
await fs.access(abs);
71+
} catch {
72+
printError(`Artifact not found: ${abs}`);
73+
this.exit(1);
74+
}
75+
metadata = { artifact_path: abs };
76+
}
77+
5478
const res = await client.projects.create({
5579
organization_id: flags.org,
5680
display_name: flags.name,
5781
plan: flags.plan,
5882
driver: flags.driver,
83+
template_id: flags.template,
5984
clone_from_project_id: flags['clone-from'],
85+
...(metadata ? { metadata } : {}),
6086
});
6187

6288
if (flags.activate && res?.project?.id) {

packages/cli/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,3 +28,4 @@ export { default as ProjectsListCommand } from './commands/projects/list.js';
2828
export { default as ProjectsShowCommand } from './commands/projects/show.js';
2929
export { default as ProjectsCreateCommand } from './commands/projects/create.js';
3030
export { default as ProjectsSwitchCommand } from './commands/projects/switch.js';
31+
export { default as ProjectsBindCommand } from './commands/projects/bind.js';

packages/client/src/index.ts

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,7 @@ export class ObjectStackClient {
649649
storage_limit_mb?: number;
650650
clone_from_project_id?: string;
651651
template_id?: string;
652+
metadata?: Record<string, unknown>;
652653
}) => {
653654
const res = await this.fetch(`${this.baseUrl}/api/v1/cloud/projects`, {
654655
method: 'POST',
@@ -986,14 +987,19 @@ export class ObjectStackClient {
986987
const route = this.getRoute('auth');
987988
const res = await this.fetch(`${this.baseUrl}${route}/sign-in/email`, {
988989
method: 'POST',
990+
headers: { Origin: this.baseUrl },
989991
body: JSON.stringify(request)
990992
});
991-
const data = await res.json();
993+
const raw = await res.json();
994+
// Normalize: better-auth returns `{ token, user }` at top level,
995+
// but our SessionResponse shape wraps them in `data`.
996+
const data = raw && (raw.data ?? (raw.token || raw.user ? { token: raw.token, user: raw.user } : undefined));
997+
const normalized = data ? { ...raw, data } : raw;
992998
// Auto-set token if present in response
993-
if (data.data?.token) {
994-
this.token = data.data.token;
999+
if (normalized.data?.token) {
1000+
this.token = normalized.data.token;
9951001
}
996-
return data;
1002+
return normalized;
9971003
},
9981004

9991005
/**
@@ -1004,7 +1010,7 @@ export class ObjectStackClient {
10041010
const route = this.getRoute('auth');
10051011
await this.fetch(`${this.baseUrl}${route}/sign-out`, {
10061012
method: 'POST',
1007-
headers: { 'Content-Type': 'application/json' },
1013+
headers: { 'Content-Type': 'application/json', Origin: this.baseUrl },
10081014
body: '{}',
10091015
});
10101016
this.token = undefined;
@@ -1016,7 +1022,9 @@ export class ObjectStackClient {
10161022
*/
10171023
me: async (): Promise<SessionResponse> => {
10181024
const route = this.getRoute('auth');
1019-
const res = await this.fetch(`${this.baseUrl}${route}/get-session`);
1025+
const res = await this.fetch(`${this.baseUrl}${route}/get-session`, {
1026+
headers: { Origin: this.baseUrl },
1027+
});
10201028
return res.json();
10211029
},
10221030

@@ -1028,13 +1036,16 @@ export class ObjectStackClient {
10281036
const route = this.getRoute('auth');
10291037
const res = await this.fetch(`${this.baseUrl}${route}/sign-up/email`, {
10301038
method: 'POST',
1039+
headers: { Origin: this.baseUrl },
10311040
body: JSON.stringify(request)
10321041
});
1033-
const data = await res.json();
1034-
if (data.data?.token) {
1035-
this.token = data.data.token;
1042+
const raw = await res.json();
1043+
const data = raw && (raw.data ?? (raw.token || raw.user ? { token: raw.token, user: raw.user } : undefined));
1044+
const normalized = data ? { ...raw, data } : raw;
1045+
if (normalized.data?.token) {
1046+
this.token = normalized.data.token;
10361047
}
1037-
return data;
1048+
return normalized;
10381049
},
10391050

10401051
/**

0 commit comments

Comments
 (0)