@lotics/cli 0.71.0 → 0.74.0
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/README.md +38 -0
- package/dist/app_commands.d.ts +13 -0
- package/dist/app_commands.js +3 -3
- package/dist/args.d.ts +17 -0
- package/dist/args.js +35 -2
- package/dist/args.test.js +49 -0
- package/dist/cli.js +235 -3
- package/dist/client.d.ts +321 -0
- package/dist/client.js +164 -0
- package/dist/dev/rpc_handler.d.ts +1 -1
- package/dist/dev/rpc_handler.js +10 -2
- package/dist/dev/rpc_handler.test.js +24 -6
- package/dist/generate_app_fields.d.ts +2 -0
- package/dist/generate_app_fields.js +1 -1
- package/dist/generate_package_fields.d.ts +48 -0
- package/dist/generate_package_fields.js +104 -0
- package/dist/generate_package_fields.test.d.ts +1 -0
- package/dist/generate_package_fields.test.js +51 -0
- package/dist/package_commands.d.ts +227 -0
- package/dist/package_commands.js +957 -0
- package/dist/package_commands.test.d.ts +1 -0
- package/dist/package_commands.test.js +303 -0
- package/dist/src/cli.js +1420 -138
- package/dist/starter_template.d.ts +1 -1
- package/dist/starter_template.js +10 -4
- package/package.json +1 -1
|
@@ -0,0 +1,957 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `lotics package *` subcommands — the app-package authoring + dev/test/run loop
|
|
3
|
+
* (see docs/app_packages.md). An app *package* is a versioned, workspace-agnostic
|
|
4
|
+
* blueprint Lotics maintains once and installs into many workspaces. This module
|
|
5
|
+
* implements the full harness:
|
|
6
|
+
*
|
|
7
|
+
* new scaffold a package project (contract + app source).
|
|
8
|
+
* build build the publishable bundle (source + dist).
|
|
9
|
+
* publish create a new immutable package version from the contract + bundle.
|
|
10
|
+
* dev scaffold-sync the package into a dev workspace (install/upgrade) and
|
|
11
|
+
* run the existing app dev server against the resulting installation.
|
|
12
|
+
* sync re-run the scaffold-sync (additive migrate + materialize) without dev.
|
|
13
|
+
* reset DEV-ONLY, hard-gated: drop the scaffolded tables + re-scaffold clean.
|
|
14
|
+
* install materialize a published version into the current workspace.
|
|
15
|
+
* eject sever an installation's package link (last rung of customization).
|
|
16
|
+
*
|
|
17
|
+
* The dev loop reuses the *published* path end-to-end: `dev`/`sync` build →
|
|
18
|
+
* publish a new version → install (first time) or upgrade (subsequent) into the
|
|
19
|
+
* dev workspace, so the dev installation goes through the exact scaffold +
|
|
20
|
+
* materialize the real install/upgrade run (no parallel dev-only materializer).
|
|
21
|
+
* The local installation pin per dev workspace is recorded in the project
|
|
22
|
+
* manifest so a re-sync upgrades in place.
|
|
23
|
+
*/
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { tmpdir } from "node:os";
|
|
27
|
+
import "./client.js";
|
|
28
|
+
import { buildStarterTemplate, STARTER_FALLBACK_SDK_VERSION } from "./starter_template.js";
|
|
29
|
+
import { generatePackageAppFields, } from "./generate_package_fields.js";
|
|
30
|
+
import { appDirName, fetchLatestNpmVersion, runNpm, runTar } from "./app_commands.js";
|
|
31
|
+
import { writeFileAtomic } from "./file_command_io.js";
|
|
32
|
+
import { startDevServer, openBrowser } from "./dev/server.js";
|
|
33
|
+
const CONTRACT_FILE = "contract.json";
|
|
34
|
+
/**
|
|
35
|
+
* The origin pin `lotics package extract` writes and `lotics package adopt`
|
|
36
|
+
* reads back: `{ app_id, workspace_id, binding }`. Lives under `.lotics/`, which
|
|
37
|
+
* is in `SOURCE_STAGE_EXCLUDES` — so it never rides along in a published bundle.
|
|
38
|
+
*/
|
|
39
|
+
const ADOPT_BINDING_FILE = "adopt_binding.json";
|
|
40
|
+
function packageJsonPath(projectDir) {
|
|
41
|
+
return path.join(projectDir, "package.json");
|
|
42
|
+
}
|
|
43
|
+
function isPlainObject(value) {
|
|
44
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
45
|
+
}
|
|
46
|
+
/** Read the package project's manifest, failing loud when the dir isn't one. */
|
|
47
|
+
export function readPackageProject(projectDir) {
|
|
48
|
+
const pkgPath = packageJsonPath(projectDir);
|
|
49
|
+
if (!fs.existsSync(pkgPath)) {
|
|
50
|
+
throw new Error(`No package.json in ${projectDir}. Run this inside a package project (lotics package new <name>).`);
|
|
51
|
+
}
|
|
52
|
+
const parsed = JSON.parse(fs.readFileSync(pkgPath, "utf-8"));
|
|
53
|
+
if (!isPlainObject(parsed))
|
|
54
|
+
throw new Error(`Malformed package.json in ${projectDir}.`);
|
|
55
|
+
const lotics = parsed.lotics;
|
|
56
|
+
const pkg = isPlainObject(lotics) ? lotics.package : undefined;
|
|
57
|
+
if (!isPlainObject(pkg) || typeof pkg.name !== "string") {
|
|
58
|
+
throw new Error(`${pkgPath} has no lotics.package manifest — not a package project. Use "lotics package new <name>".`);
|
|
59
|
+
}
|
|
60
|
+
const dev = {};
|
|
61
|
+
if (isPlainObject(pkg.dev)) {
|
|
62
|
+
for (const [ws, entry] of Object.entries(pkg.dev)) {
|
|
63
|
+
if (isPlainObject(entry) && typeof entry.app_id === "string" && typeof entry.version === "number") {
|
|
64
|
+
dev[ws] = { app_id: entry.app_id, version: entry.version };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const manifest = {
|
|
69
|
+
id: typeof pkg.id === "string" ? pkg.id : null,
|
|
70
|
+
name: pkg.name,
|
|
71
|
+
description: typeof pkg.description === "string" ? pkg.description : null,
|
|
72
|
+
version: typeof pkg.version === "number" ? pkg.version : null,
|
|
73
|
+
dev,
|
|
74
|
+
};
|
|
75
|
+
return { pkgJson: parsed, manifest };
|
|
76
|
+
}
|
|
77
|
+
/** Persist an updated manifest back into the project's package.json (atomic write). */
|
|
78
|
+
export function writePackageManifest(projectDir, project) {
|
|
79
|
+
const next = {
|
|
80
|
+
...project.pkgJson,
|
|
81
|
+
lotics: {
|
|
82
|
+
...(isPlainObject(project.pkgJson.lotics) ? project.pkgJson.lotics : {}),
|
|
83
|
+
package: project.manifest,
|
|
84
|
+
},
|
|
85
|
+
};
|
|
86
|
+
// Atomic write: a torn plain write could corrupt package.json and lose the
|
|
87
|
+
// stamped registry `package_id` — which would orphan the published package
|
|
88
|
+
// and create a duplicate on the next publish retry.
|
|
89
|
+
writeFileAtomic(packageJsonPath(projectDir), new TextEncoder().encode(JSON.stringify(next, null, 2) + "\n"));
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* The package.json to ship INSIDE `source.tar.gz`: the on-disk manifest with the
|
|
93
|
+
* author-local `lotics.package.dev` map stripped. `dev` is the author's private
|
|
94
|
+
* dev-workspace → installation bookkeeping; it must never reach a consumer (every
|
|
95
|
+
* install carries the source, and `lotics app pull` ejects it). Returns a
|
|
96
|
+
* sanitized copy — the on-disk package.json is left untouched. `id`/`name`/
|
|
97
|
+
* `description`/`version` are the package's stable identity and stay.
|
|
98
|
+
*/
|
|
99
|
+
export function sanitizePackageJsonForSource(pkgJson) {
|
|
100
|
+
const lotics = isPlainObject(pkgJson.lotics) ? pkgJson.lotics : {};
|
|
101
|
+
const pkg = isPlainObject(lotics.package) ? lotics.package : {};
|
|
102
|
+
const { dev: _dev, ...pkgWithoutDev } = pkg;
|
|
103
|
+
return {
|
|
104
|
+
...pkgJson,
|
|
105
|
+
lotics: { ...lotics, package: pkgWithoutDev },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Transform an app project's package.json (the source archive `lotics app pull`
|
|
110
|
+
* downloads, carrying the `lotics.app_id`/`workspace_id` app manifest) into a
|
|
111
|
+
* package project's `PackageProjectFile`: the app manifest is stripped ENTIRELY
|
|
112
|
+
* and a fresh, unpublished `lotics.package` manifest (id/version null) is
|
|
113
|
+
* grafted. Pure — returns a new value, never mutates the input;
|
|
114
|
+
* `writePackageManifest` writes it (atomic). The bespoke→package promotion's
|
|
115
|
+
* manifest inversion.
|
|
116
|
+
*/
|
|
117
|
+
export function draftPackageProjectFromApp(appPkgJson, args) {
|
|
118
|
+
const { lotics: _appManifest, ...rest } = appPkgJson;
|
|
119
|
+
return {
|
|
120
|
+
pkgJson: rest,
|
|
121
|
+
manifest: { id: null, name: args.name, description: args.description, version: null, dev: {} },
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Parse + validate `.lotics/adopt_binding.json` against the app being adopted.
|
|
126
|
+
* REFUSES a pin recorded for a different app: a binding maps ONE workspace's
|
|
127
|
+
* concrete ids, so replaying it onto another app would bind the wrong objects.
|
|
128
|
+
* Pure; the CLI never interprets the binding, only round-trips it to the server.
|
|
129
|
+
*/
|
|
130
|
+
export function parseAdoptBindingFile(raw, expectedAppId) {
|
|
131
|
+
if (!isPlainObject(raw) ||
|
|
132
|
+
typeof raw.app_id !== "string" ||
|
|
133
|
+
typeof raw.workspace_id !== "string" ||
|
|
134
|
+
!isPlainObject(raw.binding)) {
|
|
135
|
+
throw new Error("Malformed .lotics/adopt_binding.json — expected { app_id, workspace_id, binding }.");
|
|
136
|
+
}
|
|
137
|
+
if (raw.app_id !== expectedAppId) {
|
|
138
|
+
throw new Error(`.lotics/adopt_binding.json records app ${raw.app_id}, but you are adopting ${expectedAppId}. ` +
|
|
139
|
+
`A binding maps one app's concrete ids and must never be applied to another — ` +
|
|
140
|
+
`run "lotics package extract ${expectedAppId}" to produce the right pin.`);
|
|
141
|
+
}
|
|
142
|
+
// Boundary adapter: the file is untyped JSON; `isPlainObject` proved the shape
|
|
143
|
+
// and the CLI passes the map straight to the server (the validating authority).
|
|
144
|
+
return {
|
|
145
|
+
app_id: raw.app_id,
|
|
146
|
+
workspace_id: raw.workspace_id,
|
|
147
|
+
binding: raw.binding,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Render an extraction report grouped by severity (errors, then warnings, then
|
|
152
|
+
* info), one ` [<severity>] <area>: <message>` line each, and classify whether
|
|
153
|
+
* any `error` finding is present. An `error` ⇒ the draft is not publishable
|
|
154
|
+
* as-is, so `lotics package extract` exits non-zero. Pure — the command prints
|
|
155
|
+
* `lines` to stderr and gates on `hasError`.
|
|
156
|
+
*/
|
|
157
|
+
export function formatExtractReport(report) {
|
|
158
|
+
const order = ["error", "warning", "info"];
|
|
159
|
+
const lines = order.flatMap((severity) => report
|
|
160
|
+
.filter((f) => f.severity === severity)
|
|
161
|
+
.map((f) => ` [${f.severity}] ${f.area}: ${f.message}`));
|
|
162
|
+
return { lines, hasError: report.some((f) => f.severity === "error") };
|
|
163
|
+
}
|
|
164
|
+
function dotLoticsDirEnsured(projectDir) {
|
|
165
|
+
const dir = path.join(projectDir, ".lotics");
|
|
166
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
167
|
+
return dir;
|
|
168
|
+
}
|
|
169
|
+
/** Numeric "x.y.z" compare — no semver dep (the CLI has zero runtime deps). */
|
|
170
|
+
function cmpVersions(a, b) {
|
|
171
|
+
const pa = a.split(".").map(Number);
|
|
172
|
+
const pb = b.split(".").map(Number);
|
|
173
|
+
for (let i = 0; i < 3; i++) {
|
|
174
|
+
const d = (pa[i] || 0) - (pb[i] || 0);
|
|
175
|
+
if (d !== 0)
|
|
176
|
+
return d;
|
|
177
|
+
}
|
|
178
|
+
return 0;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The `@lotics/app-sdk` range a package project needs: the live npm latest,
|
|
182
|
+
* clamped to never fall below `STARTER_FALLBACK_SDK_VERSION` — the release
|
|
183
|
+
* that ships `getAppBinding`, which the generated `.lotics/app_fields.ts`
|
|
184
|
+
* imports. A `^0.x` caret range never crosses a minor, so pinning below the
|
|
185
|
+
* floor (offline, or npm not yet carrying the release) would permanently
|
|
186
|
+
* scaffold projects that cannot compile their own generated code.
|
|
187
|
+
*/
|
|
188
|
+
function packageSdkRange(sdkLatest) {
|
|
189
|
+
const version = sdkLatest !== null && cmpVersions(sdkLatest, STARTER_FALLBACK_SDK_VERSION) > 0
|
|
190
|
+
? sdkLatest
|
|
191
|
+
: STARTER_FALLBACK_SDK_VERSION;
|
|
192
|
+
return `^${version}`;
|
|
193
|
+
}
|
|
194
|
+
/** Read + JSON-parse the project's contract.json (the alias-keyed declaration). */
|
|
195
|
+
function readContract(projectDir) {
|
|
196
|
+
const contractPath = path.join(projectDir, CONTRACT_FILE);
|
|
197
|
+
if (!fs.existsSync(contractPath)) {
|
|
198
|
+
throw new Error(`No ${CONTRACT_FILE} in ${projectDir}. A package project declares its data model there.`);
|
|
199
|
+
}
|
|
200
|
+
return JSON.parse(fs.readFileSync(contractPath, "utf-8"));
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* (Re)generate the package project's `.lotics/app_fields.ts` from its
|
|
204
|
+
* contract.json — the runtime-resolving F/OPT/ROLE surface (see
|
|
205
|
+
* `generate_package_fields.ts`). Runs on new/extract and on every dev/sync so
|
|
206
|
+
* contract edits keep the aliases addressable. `.lotics` is in
|
|
207
|
+
* SOURCE_STAGE_EXCLUDES: the compiled module ships inside `dist/`, and a
|
|
208
|
+
* post-eject `lotics app pull` regenerates the bespoke (baked) variant.
|
|
209
|
+
*/
|
|
210
|
+
function writePackageAppFields(projectDir) {
|
|
211
|
+
const contract = readContract(projectDir);
|
|
212
|
+
const dotLotics = path.join(projectDir, ".lotics");
|
|
213
|
+
fs.mkdirSync(dotLotics, { recursive: true });
|
|
214
|
+
fs.writeFileSync(path.join(dotLotics, "app_fields.ts"), generatePackageAppFields(contract));
|
|
215
|
+
console.error("Wrote .lotics/app_fields.ts (contract-derived, runtime-resolved)");
|
|
216
|
+
}
|
|
217
|
+
/** Top-level project entries that never ship in the source archive. */
|
|
218
|
+
const SOURCE_STAGE_EXCLUDES = new Set([
|
|
219
|
+
"node_modules",
|
|
220
|
+
"dist",
|
|
221
|
+
".lotics",
|
|
222
|
+
".git",
|
|
223
|
+
"bundle.tar.gz",
|
|
224
|
+
"package.json",
|
|
225
|
+
]);
|
|
226
|
+
/**
|
|
227
|
+
* Copy the project's source tree into `sourceStage` with explicit TOP-LEVEL
|
|
228
|
+
* excludes, writing a sanitized `package.json` in place of the on-disk one.
|
|
229
|
+
* Deliberately not tar `--exclude` flags: those match at any depth (a nested
|
|
230
|
+
* `templates/dist/` would be silently dropped) and GNU tar vs bsdtar (macOS)
|
|
231
|
+
* disagree on `./`-prefixed patterns, which broke the sanitized-package.json
|
|
232
|
+
* graft on macOS.
|
|
233
|
+
*/
|
|
234
|
+
export function stagePackageSource(projectDir, sourceStage) {
|
|
235
|
+
const project = readPackageProject(projectDir);
|
|
236
|
+
fs.mkdirSync(sourceStage, { recursive: true });
|
|
237
|
+
for (const entry of fs.readdirSync(projectDir)) {
|
|
238
|
+
if (SOURCE_STAGE_EXCLUDES.has(entry) || entry.endsWith(".tsbuildinfo"))
|
|
239
|
+
continue;
|
|
240
|
+
fs.cpSync(path.join(projectDir, entry), path.join(sourceStage, entry), { recursive: true });
|
|
241
|
+
}
|
|
242
|
+
fs.writeFileSync(path.join(sourceStage, "package.json"), JSON.stringify(sanitizePackageJsonForSource(project.pkgJson), null, 2) + "\n");
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Build the publishable bundle: `npm run build` (vite → dist/), then a gzipped
|
|
246
|
+
* tarball carrying `source.tar.gz` (the pullable project tree) + `dist.tar.gz`
|
|
247
|
+
* (the prebuilt assets) as its two top-level members — the publish↔install
|
|
248
|
+
* contract the backend's `extractPackageBundle` reads. Returns the bytes.
|
|
249
|
+
*/
|
|
250
|
+
async function buildPackageBundle(projectDir) {
|
|
251
|
+
console.error("Building...");
|
|
252
|
+
await runNpm(["run", "build"], projectDir);
|
|
253
|
+
const distDir = path.join(projectDir, "dist");
|
|
254
|
+
if (!fs.existsSync(distDir)) {
|
|
255
|
+
throw new Error(`Build did not produce a dist/ directory in ${projectDir}. ` +
|
|
256
|
+
`Check that 'npm run build' is configured correctly.`);
|
|
257
|
+
}
|
|
258
|
+
// Stage the two member archives in a temp dir, then tar them into the bundle.
|
|
259
|
+
const stage = fs.mkdtempSync(path.join(tmpdir(), "lotics-pkg-"));
|
|
260
|
+
const bundlePath = path.join(tmpdir(), `lotics-bundle-${Date.now()}.tar.gz`);
|
|
261
|
+
try {
|
|
262
|
+
console.error("Packaging source...");
|
|
263
|
+
// Stage a copy of the source tree with EXPLICIT top-level excludes, then
|
|
264
|
+
// tar the stage with no --exclude flags at all. tar exclude patterns are
|
|
265
|
+
// the wrong tool twice over: they match at ANY depth (a nested
|
|
266
|
+
// `templates/dist/` would be silently dropped from the bundle), and GNU
|
|
267
|
+
// tar vs bsdtar (macOS) disagree on whether `./package.json` and
|
|
268
|
+
// `package.json` are the same pattern — bsdtar strips the leading `./`,
|
|
269
|
+
// which would also exclude the sanitized package.json grafted below.
|
|
270
|
+
//
|
|
271
|
+
// The on-disk package.json carries the author-local `lotics.package.dev`
|
|
272
|
+
// map; the stage gets a sanitized copy in its place, so that bookkeeping
|
|
273
|
+
// never ships to a consumer nor lands in an `lotics app pull` eject.
|
|
274
|
+
const sourceStage = path.join(stage, "source");
|
|
275
|
+
stagePackageSource(projectDir, sourceStage);
|
|
276
|
+
await runTar(["-czf", path.join(stage, "source.tar.gz"), "-C", sourceStage, "."], projectDir);
|
|
277
|
+
console.error("Packaging dist...");
|
|
278
|
+
await runTar(["-czf", path.join(stage, "dist.tar.gz"), "-C", distDir, "."], projectDir);
|
|
279
|
+
console.error("Bundling...");
|
|
280
|
+
await runTar(["-czf", bundlePath, "source.tar.gz", "dist.tar.gz"], stage);
|
|
281
|
+
return fs.readFileSync(bundlePath);
|
|
282
|
+
}
|
|
283
|
+
finally {
|
|
284
|
+
fs.rmSync(stage, { recursive: true, force: true });
|
|
285
|
+
if (fs.existsSync(bundlePath))
|
|
286
|
+
fs.unlinkSync(bundlePath);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
/** The minimal, valid starting contract a `package new` scaffold ships. */
|
|
290
|
+
export function starterContract(name) {
|
|
291
|
+
return {
|
|
292
|
+
entities: [
|
|
293
|
+
{
|
|
294
|
+
alias: "item",
|
|
295
|
+
label: name,
|
|
296
|
+
description: `Records managed by the ${name} package.`,
|
|
297
|
+
fields: [
|
|
298
|
+
{ alias: "name", label: "Name", type: "text", required: true },
|
|
299
|
+
{ alias: "notes", label: "Notes", type: "text" },
|
|
300
|
+
{
|
|
301
|
+
alias: "status",
|
|
302
|
+
label: "Status",
|
|
303
|
+
type: "select",
|
|
304
|
+
options: [
|
|
305
|
+
{ alias: "open", label: "Open", color: "blue" },
|
|
306
|
+
{ alias: "done", label: "Done", color: "green" },
|
|
307
|
+
],
|
|
308
|
+
},
|
|
309
|
+
],
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
roles: [],
|
|
313
|
+
templates: [],
|
|
314
|
+
queries: [
|
|
315
|
+
{
|
|
316
|
+
alias: "items",
|
|
317
|
+
ast: {
|
|
318
|
+
kind: "from_table",
|
|
319
|
+
from_entity: "item",
|
|
320
|
+
sort: [{ field_key: "name", order: "asc" }],
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
],
|
|
324
|
+
workflows: [],
|
|
325
|
+
agents: [],
|
|
326
|
+
config: [
|
|
327
|
+
{ alias: "heading", label: "List heading", type: "text", default: name },
|
|
328
|
+
],
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* `lotics package new <name> [path]` — scaffold a package project. Reuses the
|
|
333
|
+
* app starter (Vite+React+TS) for the code surface, swaps its app manifest for a
|
|
334
|
+
* package manifest, and adds a starter `contract.json`. The project publishes
|
|
335
|
+
* with `lotics package publish` and runs against a dev workspace with
|
|
336
|
+
* `lotics package dev`.
|
|
337
|
+
*/
|
|
338
|
+
export async function packageNew(args) {
|
|
339
|
+
const targetPath = path.resolve(args.targetPath ?? appDirName(args.name));
|
|
340
|
+
if (fs.existsSync(targetPath) && fs.readdirSync(targetPath).length > 0) {
|
|
341
|
+
throw new Error(`Target directory ${targetPath} is not empty.`);
|
|
342
|
+
}
|
|
343
|
+
fs.mkdirSync(targetPath, { recursive: true });
|
|
344
|
+
// The starter bakes an app manifest (app_id/workspace_id) into package.json;
|
|
345
|
+
// a package is workspace-agnostic, so those placeholders are replaced with the
|
|
346
|
+
// package manifest below. The rest of the starter (config, src, tests) is the
|
|
347
|
+
// package's code surface verbatim.
|
|
348
|
+
// Resolve the live @lotics/ui + @lotics/app-sdk versions like `app create`
|
|
349
|
+
// does — the generated .lotics/app_fields.ts imports `getAppBinding`, and a
|
|
350
|
+
// ^0.x caret range never crosses a minor, so a stale starter fallback would
|
|
351
|
+
// permanently pin the project below the API it needs (offline still works
|
|
352
|
+
// off STARTER_FALLBACK_*, kept ≥ that floor).
|
|
353
|
+
const [uiLatest, sdkLatest] = await Promise.all([
|
|
354
|
+
fetchLatestNpmVersion("@lotics/ui"),
|
|
355
|
+
fetchLatestNpmVersion("@lotics/app-sdk"),
|
|
356
|
+
]);
|
|
357
|
+
const files = buildStarterTemplate({
|
|
358
|
+
app_name: args.name,
|
|
359
|
+
app_id: "",
|
|
360
|
+
workspace_id: "",
|
|
361
|
+
ui_version: uiLatest ? `^${uiLatest}` : undefined,
|
|
362
|
+
sdk_version: packageSdkRange(sdkLatest),
|
|
363
|
+
});
|
|
364
|
+
for (const file of files) {
|
|
365
|
+
const fullPath = path.join(targetPath, file.path);
|
|
366
|
+
if (file.path === "package.json") {
|
|
367
|
+
const pkg = JSON.parse(file.content);
|
|
368
|
+
const manifest = {
|
|
369
|
+
id: null,
|
|
370
|
+
name: args.name,
|
|
371
|
+
description: null,
|
|
372
|
+
version: null,
|
|
373
|
+
dev: {},
|
|
374
|
+
};
|
|
375
|
+
pkg.lotics = { package: manifest };
|
|
376
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
377
|
+
fs.writeFileSync(fullPath, JSON.stringify(pkg, null, 2) + "\n");
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
|
381
|
+
fs.writeFileSync(fullPath, file.content);
|
|
382
|
+
}
|
|
383
|
+
fs.writeFileSync(path.join(targetPath, CONTRACT_FILE), JSON.stringify(starterContract(args.name), null, 2) + "\n");
|
|
384
|
+
writePackageAppFields(targetPath);
|
|
385
|
+
console.error(`Scaffolded ${files.length + 1} files into ${targetPath}`);
|
|
386
|
+
console.error("Installing npm dependencies...");
|
|
387
|
+
await runNpm(["install"], targetPath);
|
|
388
|
+
console.error(`\nReady. Next steps:`);
|
|
389
|
+
console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
|
|
390
|
+
console.error(` # edit contract.json + src/App.tsx, then run it against a dev workspace:`);
|
|
391
|
+
console.error(` lotics workspace create "<name> dev" --dev`);
|
|
392
|
+
console.error(` lotics package dev --workspace <dev_ws>`);
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* `lotics package build [path]` — build the publishable bundle and write it to
|
|
396
|
+
* `bundle.tar.gz` in the project. Mostly a local sanity check / CI artifact;
|
|
397
|
+
* `publish` and `dev`/`sync` build the bundle in memory directly.
|
|
398
|
+
*/
|
|
399
|
+
export async function packageBuild(args) {
|
|
400
|
+
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
401
|
+
readPackageProject(projectDir); // assert it's a package project
|
|
402
|
+
const bundle = await buildPackageBundle(projectDir);
|
|
403
|
+
const out = path.join(projectDir, "bundle.tar.gz");
|
|
404
|
+
fs.writeFileSync(out, bundle);
|
|
405
|
+
console.error(`Built ${out} (${(bundle.byteLength / 1024).toFixed(1)} KB)`);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Build + publish a new immutable package version from the project's contract +
|
|
409
|
+
* bundle. Creates the registry package on first publish (stamping its id into
|
|
410
|
+
* the manifest), then publishes the next monotonic version. Returns the new
|
|
411
|
+
* version number and the resolved package id.
|
|
412
|
+
*/
|
|
413
|
+
async function publishVersion(client, projectDir, opts) {
|
|
414
|
+
const project = readPackageProject(projectDir);
|
|
415
|
+
const contract = readContract(projectDir);
|
|
416
|
+
let packageId = project.manifest.id;
|
|
417
|
+
if (packageId === null) {
|
|
418
|
+
const pkg = await client.createAppPackage({
|
|
419
|
+
name: project.manifest.name,
|
|
420
|
+
description: project.manifest.description,
|
|
421
|
+
});
|
|
422
|
+
packageId = pkg.id;
|
|
423
|
+
project.manifest.id = packageId;
|
|
424
|
+
writePackageManifest(projectDir, project);
|
|
425
|
+
console.error(`Created package ${pkg.name} (${pkg.id}).`);
|
|
426
|
+
}
|
|
427
|
+
const bundle = await buildPackageBundle(projectDir);
|
|
428
|
+
console.error("Publishing version...");
|
|
429
|
+
const version = await client.publishAppPackageVersion(packageId, {
|
|
430
|
+
contract,
|
|
431
|
+
bundle,
|
|
432
|
+
changelog: opts.changelog ?? null,
|
|
433
|
+
});
|
|
434
|
+
project.manifest.version = version.version;
|
|
435
|
+
writePackageManifest(projectDir, project);
|
|
436
|
+
return { package_id: packageId, version: version.version };
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* `lotics package publish [path] [-m <changelog>]` — publish a new version.
|
|
440
|
+
*/
|
|
441
|
+
export async function packagePublish(client, args) {
|
|
442
|
+
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
443
|
+
const { package_id, version } = await publishVersion(client, projectDir, {
|
|
444
|
+
changelog: args.changelog,
|
|
445
|
+
});
|
|
446
|
+
console.error(`Published ${package_id} v${version}.`);
|
|
447
|
+
console.error(` Install it: lotics package install ${package_id} --version ${version}`);
|
|
448
|
+
}
|
|
449
|
+
/**
|
|
450
|
+
* Fail loud unless `workspace` is a throwaway dev workspace. The dev/sync
|
|
451
|
+
* scaffold path publishes a new version and scaffold-installs package tables into
|
|
452
|
+
* the resolved workspace, so the target MUST be a dev workspace — this mirrors
|
|
453
|
+
* the server-side `package reset` gate so a forgotten `--workspace` (prod
|
|
454
|
+
* selected) can never scaffold package tables into prod. Fails closed: an absent
|
|
455
|
+
* `is_dev` (a server that doesn't yet serialize it) is treated as non-dev.
|
|
456
|
+
*/
|
|
457
|
+
export function assertDevWorkspace(workspace) {
|
|
458
|
+
if (workspace.is_dev === true)
|
|
459
|
+
return;
|
|
460
|
+
throw new Error(`Workspace "${workspace.name}" (${workspace.id}) is not a dev workspace. ` +
|
|
461
|
+
`"lotics package dev"/"sync" scaffold package tables into the target workspace, so it must be a throwaway dev workspace. ` +
|
|
462
|
+
`Create one with "lotics workspace create <name> --dev" and pass --workspace <dev_ws>.`);
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Scaffold-sync the local package into the dev workspace: publish the current
|
|
466
|
+
* contract + bundle as a new version, then install it (first time) or upgrade
|
|
467
|
+
* the recorded installation (subsequent runs) so the dev workspace goes through
|
|
468
|
+
* the exact scaffold + materialize the real install/upgrade run. Records the
|
|
469
|
+
* installation pin per dev workspace in the manifest. Returns the dev
|
|
470
|
+
* installation's app id + name.
|
|
471
|
+
*/
|
|
472
|
+
async function syncToDevWorkspace(client, projectDir) {
|
|
473
|
+
const devWorkspaceId = client.getWorkspaceId();
|
|
474
|
+
if (!devWorkspaceId) {
|
|
475
|
+
throw new Error("No dev workspace selected. Pass --workspace <dev_ws> (a workspace created with --dev).");
|
|
476
|
+
}
|
|
477
|
+
// Guard BEFORE publishing: dev/sync scaffold-installs package tables into the
|
|
478
|
+
// resolved workspace, so it must be a throwaway dev workspace — a developer who
|
|
479
|
+
// forgot --workspace with prod selected would otherwise scaffold into prod.
|
|
480
|
+
// Mirrors the server-side `package reset` gate (which refuses any non-`is_dev`
|
|
481
|
+
// workspace).
|
|
482
|
+
const workspace = await client.getWorkspaceInfo(devWorkspaceId);
|
|
483
|
+
if (!workspace) {
|
|
484
|
+
throw new Error(`Workspace ${devWorkspaceId} is not accessible with these credentials. Pass --workspace <dev_ws> (a workspace created with --dev).`);
|
|
485
|
+
}
|
|
486
|
+
assertDevWorkspace(workspace);
|
|
487
|
+
// Contract may have been edited since the last sync — regenerate the
|
|
488
|
+
// runtime F/OPT/ROLE surface before the build that publishVersion runs.
|
|
489
|
+
writePackageAppFields(projectDir);
|
|
490
|
+
const { package_id, version } = await publishVersion(client, projectDir, {
|
|
491
|
+
changelog: "dev sync",
|
|
492
|
+
});
|
|
493
|
+
// Re-read after publish (publishVersion may have stamped the package id).
|
|
494
|
+
const project = readPackageProject(projectDir);
|
|
495
|
+
const existing = project.manifest.dev[devWorkspaceId];
|
|
496
|
+
let appId;
|
|
497
|
+
if (existing) {
|
|
498
|
+
console.error(`Upgrading dev installation ${existing.app_id} → v${version}...`);
|
|
499
|
+
const app = await client.upgradeAppPackage(existing.app_id, { version });
|
|
500
|
+
appId = app.id;
|
|
501
|
+
}
|
|
502
|
+
else {
|
|
503
|
+
console.error(`Installing ${package_id} v${version} into dev workspace ${devWorkspaceId}...`);
|
|
504
|
+
const app = await client.installAppPackage(package_id, { version });
|
|
505
|
+
appId = app.id;
|
|
506
|
+
}
|
|
507
|
+
project.manifest.dev[devWorkspaceId] = { app_id: appId, version };
|
|
508
|
+
writePackageManifest(projectDir, project);
|
|
509
|
+
const installed = await client.getApp(appId);
|
|
510
|
+
return { app_id: appId, app_name: installed.name, workspace_id: devWorkspaceId, version };
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* `lotics package sync [path]` — re-run the scaffold-sync into the dev workspace
|
|
514
|
+
* (the continuous loop: edit contract → sync additively migrates + re-materializes).
|
|
515
|
+
*/
|
|
516
|
+
export async function packageSync(client, args) {
|
|
517
|
+
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
518
|
+
const result = await syncToDevWorkspace(client, projectDir);
|
|
519
|
+
console.error(`Synced ${result.app_name} v${result.version} → ${result.app_id} (workspace ${result.workspace_id}).`);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* `lotics package dev [path] [--workspace <dev_ws>] [--view-as <member>]` —
|
|
523
|
+
* scaffold-sync the package into the dev workspace, then run the existing app
|
|
524
|
+
* dev server against the resulting installation (HMR over the local source, RPC
|
|
525
|
+
* forwarded to the live installation). The inner loop: edit contract → re-run to
|
|
526
|
+
* sync; edit code → hot reload.
|
|
527
|
+
*/
|
|
528
|
+
export async function packageDev(client, args) {
|
|
529
|
+
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
530
|
+
const synced = await syncToDevWorkspace(client, projectDir);
|
|
531
|
+
const handle = await startDevServer({
|
|
532
|
+
projectDir,
|
|
533
|
+
app_id: synced.app_id,
|
|
534
|
+
app_name: synced.app_name,
|
|
535
|
+
workspace_id: synced.workspace_id,
|
|
536
|
+
api_url: client.baseUrl,
|
|
537
|
+
port: args.port,
|
|
538
|
+
vitePort: args.vitePort,
|
|
539
|
+
client,
|
|
540
|
+
});
|
|
541
|
+
await handle.ready;
|
|
542
|
+
const url = `http://localhost:${handle.port}`;
|
|
543
|
+
console.error(`\n lotics package dev`);
|
|
544
|
+
console.error(` package: ${synced.app_name} (dev installation ${synced.app_id} v${synced.version})`);
|
|
545
|
+
console.error(` workspace: ${synced.workspace_id} (dev)`);
|
|
546
|
+
if (client.viewAsMemberId) {
|
|
547
|
+
console.error(` view as: ${client.viewAsMemberId}`);
|
|
548
|
+
}
|
|
549
|
+
console.error(` vite: http://localhost:${handle.vitePort}/`);
|
|
550
|
+
console.error(` open: ${url}`);
|
|
551
|
+
console.error(` rpc: ${client.baseUrl} (via Bearer API key)\n`);
|
|
552
|
+
console.error(` Edit contract.json then re-run "lotics package dev" / "lotics package sync" to migrate.`);
|
|
553
|
+
console.error(` Ctrl-C to stop.\n`);
|
|
554
|
+
openBrowser(url);
|
|
555
|
+
await new Promise((resolve) => {
|
|
556
|
+
const onSig = () => {
|
|
557
|
+
process.off("SIGINT", onSig);
|
|
558
|
+
process.off("SIGTERM", onSig);
|
|
559
|
+
resolve();
|
|
560
|
+
};
|
|
561
|
+
process.on("SIGINT", onSig);
|
|
562
|
+
process.on("SIGTERM", onSig);
|
|
563
|
+
});
|
|
564
|
+
console.error("\nStopping…");
|
|
565
|
+
await handle.stop();
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* `lotics package reset [path]` — DEV-ONLY, hard-gated. Drops the dev
|
|
569
|
+
* installation's package-owned scaffolded tables and re-scaffolds them clean. The
|
|
570
|
+
* backend refuses any workspace not flagged as a dev workspace, so this can never
|
|
571
|
+
* erase a real workspace's data. The dev installation is resolved from the
|
|
572
|
+
* project manifest's pin for the selected workspace.
|
|
573
|
+
*/
|
|
574
|
+
export async function packageReset(client, args) {
|
|
575
|
+
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
576
|
+
const devWorkspaceId = client.getWorkspaceId();
|
|
577
|
+
if (!devWorkspaceId) {
|
|
578
|
+
throw new Error("No dev workspace selected. Pass --workspace <dev_ws> (a workspace created with --dev).");
|
|
579
|
+
}
|
|
580
|
+
const { manifest } = readPackageProject(projectDir);
|
|
581
|
+
const pin = manifest.dev[devWorkspaceId];
|
|
582
|
+
if (!pin) {
|
|
583
|
+
throw new Error(`No dev installation recorded for workspace ${devWorkspaceId}. Run "lotics package dev --workspace ${devWorkspaceId}" first.`);
|
|
584
|
+
}
|
|
585
|
+
console.error(`Resetting dev installation ${pin.app_id} in workspace ${devWorkspaceId}...`);
|
|
586
|
+
const app = await client.resetAppPackage(pin.app_id);
|
|
587
|
+
console.error(`Reset ${app.name} → ${app.id}. The scaffolded tables were dropped and re-created clean.`);
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Resolve the installation to operate on: an explicit app id wins; otherwise
|
|
591
|
+
* fall back to the project manifest's dev pin for the selected workspace (the
|
|
592
|
+
* same resolution `package reset` uses).
|
|
593
|
+
*/
|
|
594
|
+
function resolveInstallationAppId(client, explicit) {
|
|
595
|
+
if (explicit)
|
|
596
|
+
return explicit;
|
|
597
|
+
const workspaceId = client.getWorkspaceId();
|
|
598
|
+
if (!workspaceId) {
|
|
599
|
+
throw new Error("Pass an app id (lotics package doctor <app_id>) or select a workspace.");
|
|
600
|
+
}
|
|
601
|
+
let manifest;
|
|
602
|
+
try {
|
|
603
|
+
({ manifest } = readPackageProject(path.resolve(process.cwd())));
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
// CLI usage boundary: translate "this directory isn't a package project"
|
|
607
|
+
// into the action the caller actually needs.
|
|
608
|
+
throw new Error("No app id given and the current directory is not a package project. " +
|
|
609
|
+
"Pass one explicitly: lotics package doctor <app_id>.");
|
|
610
|
+
}
|
|
611
|
+
const pin = manifest.dev[workspaceId];
|
|
612
|
+
if (!pin) {
|
|
613
|
+
throw new Error(`No app id given and no dev installation recorded for workspace ${workspaceId}. ` +
|
|
614
|
+
"Pass one explicitly: lotics package doctor <app_id>.");
|
|
615
|
+
}
|
|
616
|
+
return pin.app_id;
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* Parse repeated `--resolve <key>=<value>` flags. Drift entries
|
|
620
|
+
* (`<namespace>.<alias>`) take `recreate` or an existing id; modified
|
|
621
|
+
* artifacts (`<kind>.<alias>`) take `revert` or `keep`. Any other value is a
|
|
622
|
+
* bind_to id; the server validates value-kind against what the key resolves.
|
|
623
|
+
*/
|
|
624
|
+
export function parseResolveFlags(resolve) {
|
|
625
|
+
const resolutions = {};
|
|
626
|
+
for (const entry of resolve) {
|
|
627
|
+
const eq = entry.indexOf("=");
|
|
628
|
+
if (eq <= 0 || eq === entry.length - 1) {
|
|
629
|
+
throw new Error(`Invalid --resolve "${entry}" — expected <key>=recreate|revert|keep or <key>=<existing_id>.`);
|
|
630
|
+
}
|
|
631
|
+
const key = entry.slice(0, eq);
|
|
632
|
+
const value = entry.slice(eq + 1);
|
|
633
|
+
resolutions[key] =
|
|
634
|
+
value === "recreate" || value === "revert" || value === "keep" ? value : { bind_to: value };
|
|
635
|
+
}
|
|
636
|
+
return resolutions;
|
|
637
|
+
}
|
|
638
|
+
/**
|
|
639
|
+
* Health check: version pin vs. registry latest + binding drift. Exits
|
|
640
|
+
* non-zero when drift is found so scripts can gate on it.
|
|
641
|
+
*/
|
|
642
|
+
export async function packageDoctor(client, args) {
|
|
643
|
+
const app_id = resolveInstallationAppId(client, args.app_id);
|
|
644
|
+
const health = await client.getAppPackageHealth(app_id);
|
|
645
|
+
console.error(`${health.package_name} — installation ${health.app_id}`);
|
|
646
|
+
console.error(` Installed: v${health.installed_version} Latest: v${health.latest_version}` +
|
|
647
|
+
(health.update_available ? " → update available" : ""));
|
|
648
|
+
if (health.drift.length === 0) {
|
|
649
|
+
console.error(" Binding: healthy — every bound alias resolves.");
|
|
650
|
+
}
|
|
651
|
+
else {
|
|
652
|
+
console.error(` Binding drift (${health.drift.length}):`);
|
|
653
|
+
for (const d of health.drift) {
|
|
654
|
+
console.error(` - ${d.namespace}.${d.alias} → ${d.id} (missing from the workspace)`);
|
|
655
|
+
}
|
|
656
|
+
console.error(` Resolve while upgrading:\n lotics package upgrade ${app_id}` +
|
|
657
|
+
` --resolve <namespace.alias>=recreate (or =<existing_id> to re-point)`);
|
|
658
|
+
process.exitCode = 1;
|
|
659
|
+
}
|
|
660
|
+
if (health.modified.length === 0) {
|
|
661
|
+
console.error(" Package artifacts: pristine — no local edits an upgrade would revert.");
|
|
662
|
+
}
|
|
663
|
+
else {
|
|
664
|
+
console.error(` Locally modified package artifacts (${health.modified.length}):`);
|
|
665
|
+
for (const m of health.modified) {
|
|
666
|
+
console.error(` - ${m.kind}.${m.alias} (an upgrade overwrites this unless kept)`);
|
|
667
|
+
}
|
|
668
|
+
console.error(` Consent while upgrading:\n lotics package upgrade ${app_id}` +
|
|
669
|
+
` --resolve <kind.alias>=revert (or =keep to retain the edit)`);
|
|
670
|
+
process.exitCode = 1;
|
|
671
|
+
}
|
|
672
|
+
if (health.update_available) {
|
|
673
|
+
console.error(` Upgrade: lotics package upgrade ${app_id}`);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Preview-then-apply upgrade. Prints the additive plan + informational
|
|
678
|
+
* removals; refuses (exit 1, with the exact --resolve syntax) while any
|
|
679
|
+
* binding drift lacks a resolution.
|
|
680
|
+
*/
|
|
681
|
+
export async function packageUpgrade(client, args) {
|
|
682
|
+
const preview = await client.previewAppPackageUpgrade(args.app_id, {
|
|
683
|
+
...(args.version !== undefined ? { version: args.version } : {}),
|
|
684
|
+
});
|
|
685
|
+
console.error(`Upgrade v${preview.from_version} → v${preview.to_version}` +
|
|
686
|
+
(preview.changelog ? ` — ${preview.changelog}` : ""));
|
|
687
|
+
const summarize = (sets) => Object.entries(sets)
|
|
688
|
+
.filter(([, entries]) => entries.length > 0)
|
|
689
|
+
.map(([kind, entries]) => `${entries.length} ${kind}`)
|
|
690
|
+
.join(", ");
|
|
691
|
+
const added = summarize(preview.diff.added);
|
|
692
|
+
const removed = summarize(preview.diff.removed);
|
|
693
|
+
if (added)
|
|
694
|
+
console.error(` Adds: ${added}`);
|
|
695
|
+
if (removed)
|
|
696
|
+
console.error(` Unbinds (workspace data kept): ${removed}`);
|
|
697
|
+
// Breaking contract changes are NOT resolvable via --resolve — scaffold would
|
|
698
|
+
// refuse them on a bound alias, and there is no in-flow remediation. Report
|
|
699
|
+
// every entry and the only two real remedies, then hard-stop.
|
|
700
|
+
if (preview.diff.breaking.length > 0) {
|
|
701
|
+
console.error(" Breaking contract changes — NOT resolvable via --resolve (a bound alias's shape changed):");
|
|
702
|
+
for (const b of preview.diff.breaking) {
|
|
703
|
+
console.error(` - ${b.entity}.${b.alias} (${b.kind}): ${b.from} → ${b.to}`);
|
|
704
|
+
}
|
|
705
|
+
console.error(" There is no in-flow resolution for this class. The package author must publish a version\n" +
|
|
706
|
+
" that keeps these aliases' shape stable, or eject this app (severing the package link)\n" +
|
|
707
|
+
` before making the change: lotics package eject ${args.app_id}`);
|
|
708
|
+
process.exit(1);
|
|
709
|
+
}
|
|
710
|
+
const unresolvedDrift = preview.drift.filter((d) => args.resolutions[`${d.namespace}.${d.alias}`] === undefined);
|
|
711
|
+
const unresolvedModified = preview.modified.filter((m) => args.resolutions[`${m.kind}.${m.alias}`] === undefined);
|
|
712
|
+
if (unresolvedDrift.length > 0 || unresolvedModified.length > 0) {
|
|
713
|
+
if (unresolvedDrift.length > 0) {
|
|
714
|
+
console.error(" Binding drift must be resolved before upgrading:");
|
|
715
|
+
for (const d of unresolvedDrift) {
|
|
716
|
+
console.error(` --resolve ${d.namespace}.${d.alias}=recreate (or =<existing_id> to re-point; was ${d.id})`);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
if (unresolvedModified.length > 0) {
|
|
720
|
+
console.error(" Locally modified package artifacts need consent before upgrading:");
|
|
721
|
+
for (const m of unresolvedModified) {
|
|
722
|
+
console.error(` --resolve ${m.kind}.${m.alias}=revert (overwrite the local edit; or =keep to retain it)`);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
process.exit(1);
|
|
726
|
+
}
|
|
727
|
+
const app = await client.upgradeAppPackage(args.app_id, {
|
|
728
|
+
...(args.version !== undefined ? { version: args.version } : {}),
|
|
729
|
+
...(Object.keys(args.resolutions).length > 0 ? { resolutions: args.resolutions } : {}),
|
|
730
|
+
});
|
|
731
|
+
console.error(`Upgraded ${app.name} → v${app.package_version} (${app.id}).`);
|
|
732
|
+
}
|
|
733
|
+
export async function packageInstall(client, args) {
|
|
734
|
+
// Surface the trust badge at the consent point: installing materializes the
|
|
735
|
+
// package's workflows/agents under YOUR authority, so say whose package it is.
|
|
736
|
+
const pkg = await client.getAppPackage(args.package_id);
|
|
737
|
+
console.error(pkg.is_official
|
|
738
|
+
? `Installing ${pkg.name} — official Lotics package...`
|
|
739
|
+
: `Installing ${pkg.name} — third-party package (runs under your authority once installed)...`);
|
|
740
|
+
const app = await client.installAppPackage(args.package_id, {
|
|
741
|
+
...(args.version !== undefined ? { version: args.version } : {}),
|
|
742
|
+
});
|
|
743
|
+
const versionLabel = app.package_version !== null ? `v${app.package_version}` : "(unknown version)";
|
|
744
|
+
console.error(`Installed ${app.name} ${versionLabel} → ${app.id} (workspace ${app.workspace_id}).`);
|
|
745
|
+
console.error(" The data model, queries, workflows, and agents are live.");
|
|
746
|
+
console.error(" Pull it for local editing: lotics app pull " + app.id);
|
|
747
|
+
}
|
|
748
|
+
export async function packageEject(client, args) {
|
|
749
|
+
const app = await client.ejectAppPackage(args.app_id);
|
|
750
|
+
console.error(`Ejected ${app.name} → ${app.id} (workspace ${app.workspace_id}).`);
|
|
751
|
+
console.error(" The package link is severed — it's now a normal bespoke app and can no longer be upgraded.");
|
|
752
|
+
console.error(" Its data model, queries, workflows, and templates are unchanged.");
|
|
753
|
+
console.error(" Pull the pinned source for local editing: lotics app pull " + app.id);
|
|
754
|
+
}
|
|
755
|
+
/**
|
|
756
|
+
* `lotics package extract <app_id> [path]` — promote a bespoke app to a DRAFT
|
|
757
|
+
* package project (docs/app_packages.md § Promotion). Calls the extract read,
|
|
758
|
+
* prints the findings report grouped by severity, then ALWAYS emits the draft
|
|
759
|
+
* project (a broken contract is still the reviewable starting point): the app's
|
|
760
|
+
* current source archive (same mechanics as `lotics app pull`) with the app
|
|
761
|
+
* manifest swapped for an unpublished package manifest, `contract.json`, the
|
|
762
|
+
* file-backed templates staged at their `bytes_ref` paths, and
|
|
763
|
+
* `.lotics/adopt_binding.json` (the origin pin the `adopt` step reads back).
|
|
764
|
+
* Exits non-zero when any `error` finding exists — the draft is written, but
|
|
765
|
+
* publish re-validates and nothing should ship unreviewed.
|
|
766
|
+
*/
|
|
767
|
+
export async function packageExtract(client, args) {
|
|
768
|
+
const app = await client.getApp(args.app_id);
|
|
769
|
+
if (!app.current_version_id) {
|
|
770
|
+
throw new Error(`App ${app.id} has no deployed version — extract stages its source archive, so deploy it first.`);
|
|
771
|
+
}
|
|
772
|
+
const extracted = await client.extractAppPackage(args.app_id);
|
|
773
|
+
const { lines, hasError } = formatExtractReport(extracted.report);
|
|
774
|
+
if (lines.length > 0) {
|
|
775
|
+
console.error(`Extraction report (${extracted.report.length}):`);
|
|
776
|
+
for (const line of lines)
|
|
777
|
+
console.error(line);
|
|
778
|
+
}
|
|
779
|
+
else {
|
|
780
|
+
console.error("Extraction report: no findings.");
|
|
781
|
+
}
|
|
782
|
+
const targetPath = path.resolve(args.targetPath ?? appDirName(app.name));
|
|
783
|
+
if (fs.existsSync(targetPath) && fs.readdirSync(targetPath).length > 0) {
|
|
784
|
+
throw new Error(`Target directory ${targetPath} is not empty.`);
|
|
785
|
+
}
|
|
786
|
+
fs.mkdirSync(targetPath, { recursive: true });
|
|
787
|
+
// Pull the app's current source archive — same mechanics as `lotics app pull`
|
|
788
|
+
// (getAppVersion → presigned source URL → download → untar into the project).
|
|
789
|
+
const version = await client.getAppVersion(app.id, app.current_version_id);
|
|
790
|
+
const sourceUrl = await client.getAppVersionSourceUrl(app.id, version.id);
|
|
791
|
+
const tmpFile = path.join(tmpdir(), `lotics-extract-${app.id}-${Date.now()}.tar.gz`);
|
|
792
|
+
console.error("Downloading source archive...");
|
|
793
|
+
await client.downloadFile(sourceUrl, tmpFile);
|
|
794
|
+
try {
|
|
795
|
+
console.error(`Extracting to ${targetPath}...`);
|
|
796
|
+
await runTar(["-xzf", tmpFile, "-C", targetPath], targetPath);
|
|
797
|
+
}
|
|
798
|
+
finally {
|
|
799
|
+
if (fs.existsSync(tmpFile))
|
|
800
|
+
fs.unlinkSync(tmpFile);
|
|
801
|
+
}
|
|
802
|
+
// Swap the app manifest (lotics.app_id/workspace_id) for a fresh, unpublished
|
|
803
|
+
// package manifest — atomic write via the existing manifest writer.
|
|
804
|
+
const appPkgJson = JSON.parse(fs.readFileSync(packageJsonPath(targetPath), "utf-8"));
|
|
805
|
+
const draft = draftPackageProjectFromApp(appPkgJson, { name: app.name, description: null });
|
|
806
|
+
// The origin app's package.json rides in the source archive with ITS OWN
|
|
807
|
+
// @lotics/app-sdk range — raise it to the getAppBinding floor the generated
|
|
808
|
+
// .lotics/app_fields.ts needs (never lower an already-newer range).
|
|
809
|
+
const deps = isPlainObject(draft.pkgJson.dependencies) ? draft.pkgJson.dependencies : {};
|
|
810
|
+
const originRange = typeof deps["@lotics/app-sdk"] === "string" ? deps["@lotics/app-sdk"] : null;
|
|
811
|
+
const originFloor = originRange?.replace(/^[\^~]/, "") ?? null;
|
|
812
|
+
if (originFloor === null || cmpVersions(originFloor, STARTER_FALLBACK_SDK_VERSION) < 0) {
|
|
813
|
+
const raised = packageSdkRange(await fetchLatestNpmVersion("@lotics/app-sdk"));
|
|
814
|
+
draft.pkgJson.dependencies = { ...deps, "@lotics/app-sdk": raised };
|
|
815
|
+
console.error(`Raised @lotics/app-sdk to ${raised} (generated app_fields needs getAppBinding)`);
|
|
816
|
+
}
|
|
817
|
+
writePackageManifest(targetPath, draft);
|
|
818
|
+
// The alias-keyed draft contract — the reviewable master. Pretty + trailing newline.
|
|
819
|
+
fs.writeFileSync(path.join(targetPath, CONTRACT_FILE), JSON.stringify(extracted.contract, null, 2) + "\n");
|
|
820
|
+
console.error(`Wrote ${CONTRACT_FILE}`);
|
|
821
|
+
// The origin's baked `.lotics/app_fields.ts` (concrete ids) never travels —
|
|
822
|
+
// regenerate it contract-derived so `F`/`OPT` resolve per-install at runtime
|
|
823
|
+
// and the extracted source compiles unchanged.
|
|
824
|
+
writePackageAppFields(targetPath);
|
|
825
|
+
// vite.config.ts is starter-owned machinery — refresh it wholesale so the
|
|
826
|
+
// package project carries the current starter config (notably
|
|
827
|
+
// `build.target: "es2022"`, which the generated app_fields' top-level await
|
|
828
|
+
// requires). Origin-side dev customizations (e.g. `lotics ui link` aliases)
|
|
829
|
+
// don't belong in a package.
|
|
830
|
+
const starterViteConfig = buildStarterTemplate({
|
|
831
|
+
app_name: app.name,
|
|
832
|
+
app_id: "",
|
|
833
|
+
workspace_id: "",
|
|
834
|
+
}).find((f) => f.path === "vite.config.ts");
|
|
835
|
+
if (starterViteConfig === undefined) {
|
|
836
|
+
throw new Error("starter template is missing vite.config.ts — cannot refresh the package project");
|
|
837
|
+
}
|
|
838
|
+
const viteConfigPath = path.join(targetPath, "vite.config.ts");
|
|
839
|
+
const originViteConfig = fs.existsSync(viteConfigPath)
|
|
840
|
+
? fs.readFileSync(viteConfigPath, "utf-8")
|
|
841
|
+
: null;
|
|
842
|
+
fs.writeFileSync(viteConfigPath, starterViteConfig.content);
|
|
843
|
+
if (originViteConfig !== null && originViteConfig !== starterViteConfig.content) {
|
|
844
|
+
// The origin may carry legitimate customizations (optimizeDeps entries,
|
|
845
|
+
// plugins) beyond the dev-links that must not ship — never destroy them
|
|
846
|
+
// silently: stash the original for manual re-application.
|
|
847
|
+
const stash = path.join(dotLoticsDirEnsured(targetPath), "vite.config.origin.ts");
|
|
848
|
+
fs.writeFileSync(stash, originViteConfig);
|
|
849
|
+
console.error("Refreshed vite.config.ts from the starter (build.target es2022). The origin app's config " +
|
|
850
|
+
"differed — its original was saved to .lotics/vite.config.origin.ts; re-apply any " +
|
|
851
|
+
"custom optimizeDeps/plugins entries you still need (never dev-link aliases).");
|
|
852
|
+
}
|
|
853
|
+
else {
|
|
854
|
+
console.error("Refreshed vite.config.ts from the starter (build.target es2022)");
|
|
855
|
+
}
|
|
856
|
+
// Stage each file-backed template at the `bytes_ref` the contract references.
|
|
857
|
+
// The download lands in the target dir (fresh, so no name collision) under the
|
|
858
|
+
// file's own name; rename it to the exact `bytes_ref` basename the contract
|
|
859
|
+
// points at. Processed sequentially so a prior rename frees the name.
|
|
860
|
+
for (const tf of extracted.template_files) {
|
|
861
|
+
const dest = path.join(targetPath, tf.bytes_ref);
|
|
862
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
863
|
+
const { path: downloaded } = await client.downloadFileById(tf.file_id, path.dirname(dest));
|
|
864
|
+
if (path.resolve(downloaded) !== path.resolve(dest))
|
|
865
|
+
fs.renameSync(downloaded, dest);
|
|
866
|
+
console.error(`Wrote ${tf.bytes_ref}`);
|
|
867
|
+
}
|
|
868
|
+
// The origin pin the `adopt` step reads back. `.lotics` is in
|
|
869
|
+
// SOURCE_STAGE_EXCLUDES, so this never ships in a published bundle.
|
|
870
|
+
const dotLotics = path.join(targetPath, ".lotics");
|
|
871
|
+
fs.mkdirSync(dotLotics, { recursive: true });
|
|
872
|
+
fs.writeFileSync(path.join(dotLotics, ADOPT_BINDING_FILE), JSON.stringify({ app_id: app.id, workspace_id: app.workspace_id, binding: extracted.binding }, null, 2) + "\n");
|
|
873
|
+
console.error(`Wrote .lotics/${ADOPT_BINDING_FILE}`);
|
|
874
|
+
console.error("Installing npm dependencies...");
|
|
875
|
+
await runNpm(["install"], targetPath);
|
|
876
|
+
if (hasError) {
|
|
877
|
+
console.error("\nExtraction produced error findings — the draft is written but NOT publishable as-is.");
|
|
878
|
+
console.error("Fix the reported items above, then publish (which re-validates the contract).");
|
|
879
|
+
process.exitCode = 1;
|
|
880
|
+
return;
|
|
881
|
+
}
|
|
882
|
+
console.error("\nDraft package project written. Next steps:");
|
|
883
|
+
console.error(` cd ${path.relative(process.cwd(), targetPath) || "."}`);
|
|
884
|
+
console.error(" # review the aliases in contract.json, then:");
|
|
885
|
+
console.error(` lotics package publish -m "v1"`);
|
|
886
|
+
console.error(` lotics package adopt ${app.id} (from this project, same workspace)`);
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* `lotics package adopt <app_id> [path]` — bind the published package project
|
|
890
|
+
* onto the origin app (docs/app_packages.md § Promotion). Reads the project
|
|
891
|
+
* manifest (must be published — refuses otherwise), resolves the version
|
|
892
|
+
* (`--version N` else the manifest's), and reads `.lotics/adopt_binding.json`,
|
|
893
|
+
* REFUSING a pin recorded for a different app. On success the app becomes
|
|
894
|
+
* installation #1 and is upgradeable again; a server ConflictError (naming the
|
|
895
|
+
* unfaithful aliases) surfaces verbatim.
|
|
896
|
+
*/
|
|
897
|
+
export async function packageAdopt(client, args) {
|
|
898
|
+
const projectDir = path.resolve(args.projectDir ?? process.cwd());
|
|
899
|
+
const { manifest } = readPackageProject(projectDir);
|
|
900
|
+
if (manifest.id === null) {
|
|
901
|
+
throw new Error("This package project has never been published (no package id). Publish first:\n lotics package publish");
|
|
902
|
+
}
|
|
903
|
+
const version = args.version ?? manifest.version;
|
|
904
|
+
if (version === null) {
|
|
905
|
+
throw new Error("No version to adopt — publish this project first (lotics package publish) or pass --version N.");
|
|
906
|
+
}
|
|
907
|
+
const bindingPath = path.join(projectDir, ".lotics", ADOPT_BINDING_FILE);
|
|
908
|
+
if (!fs.existsSync(bindingPath)) {
|
|
909
|
+
throw new Error(`No .lotics/${ADOPT_BINDING_FILE} in ${projectDir}. Adopt binds the origin app that ` +
|
|
910
|
+
`"lotics package extract" recorded — run extract to produce this project.`);
|
|
911
|
+
}
|
|
912
|
+
const pin = parseAdoptBindingFile(JSON.parse(fs.readFileSync(bindingPath, "utf-8")), args.app_id);
|
|
913
|
+
const app = await client.adoptAppPackage(args.app_id, {
|
|
914
|
+
package_id: manifest.id,
|
|
915
|
+
version,
|
|
916
|
+
binding: pin.binding,
|
|
917
|
+
});
|
|
918
|
+
console.error(`Adopted ${app.name} → installation of ${app.package_id} v${app.package_version}.`);
|
|
919
|
+
console.error(` Verify the installation: lotics package doctor ${app.id}`);
|
|
920
|
+
}
|
|
921
|
+
/**
|
|
922
|
+
* `lotics package fleet-upgrade <package_id> [--version N]` — bring every
|
|
923
|
+
* installation of the package across the caller's org to the target version.
|
|
924
|
+
* Hands-off applies only where the preview is clean; skipped/failed
|
|
925
|
+
* installations are reported per line and the process exits 1 so a release
|
|
926
|
+
* script can gate on "fleet fully current".
|
|
927
|
+
*/
|
|
928
|
+
export async function packageFleetUpgrade(client, args) {
|
|
929
|
+
const result = await client.fleetUpgradeAppPackage(args.package_id, {
|
|
930
|
+
...(args.version !== undefined ? { version: args.version } : {}),
|
|
931
|
+
});
|
|
932
|
+
console.error(`Fleet upgrade of ${result.package_id} → v${result.target_version}:`);
|
|
933
|
+
if (result.installations.length === 0) {
|
|
934
|
+
console.error(" No installations of this package in your organization.");
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
const counts = { upgraded: 0, up_to_date: 0, skipped: 0, failed: 0 };
|
|
938
|
+
for (const inst of result.installations) {
|
|
939
|
+
counts[inst.outcome]++;
|
|
940
|
+
const from = inst.from_version === null ? "?" : `v${inst.from_version}`;
|
|
941
|
+
const line = ` [${inst.outcome}] ${inst.workspace_name} — ${inst.app_name} (${from} → v${result.target_version})`;
|
|
942
|
+
if (inst.outcome === "skipped" && inst.blockers) {
|
|
943
|
+
console.error(`${line}: breaking=${inst.blockers.breaking} drift=${inst.blockers.drift} modified=${inst.blockers.modified}`);
|
|
944
|
+
console.error(` resolve via: lotics package upgrade ${inst.app_id} --version ${result.target_version} ...`);
|
|
945
|
+
}
|
|
946
|
+
else if (inst.message) {
|
|
947
|
+
console.error(`${line}: ${inst.message}`);
|
|
948
|
+
}
|
|
949
|
+
else {
|
|
950
|
+
console.error(line);
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
console.error(`Done: ${counts.upgraded} upgraded, ${counts.up_to_date} already current, ${counts.skipped} skipped, ${counts.failed} failed.`);
|
|
954
|
+
if (counts.skipped > 0 || counts.failed > 0) {
|
|
955
|
+
process.exitCode = 1;
|
|
956
|
+
}
|
|
957
|
+
}
|