workbite 0.1.0 → 0.1.1
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 +7 -2
- package/lib/cli.js +16 -5
- package/lib/install.js +12 -3
- package/lib/runtime.js +66 -3
- package/package.json +1 -1
- package/release.json +1 -0
package/README.md
CHANGED
|
@@ -6,8 +6,8 @@ WorkBite installs and opens **Work Stack** (organizes the work) and
|
|
|
6
6
|
```sh
|
|
7
7
|
npm install -g workbite
|
|
8
8
|
workbite install
|
|
9
|
-
workbite open --workspace /abs/path/new-folder --init
|
|
10
|
-
workbite
|
|
9
|
+
workbite open --workspace /abs/path/new-folder --init # first time, once
|
|
10
|
+
workbite # afterwards
|
|
11
11
|
```
|
|
12
12
|
|
|
13
13
|
Node.js 20+ on Windows x64 or Linux x64 (glibc 2.28+).
|
|
@@ -21,6 +21,11 @@ Node.js 20+ on Windows x64 or Linux x64 (glibc 2.28+).
|
|
|
21
21
|
workspace's Work Stack owner and a StackBite backend (only processes it can
|
|
22
22
|
prove it started), then runs the Work Stack terminal UI. Backends keep
|
|
23
23
|
running after the UI exits.
|
|
24
|
+
- `workbite` (or `workbite open` without `--workspace`) reopens the last
|
|
25
|
+
workspace that opened successfully; an explicit `--workspace` always wins.
|
|
26
|
+
It refuses, starting nothing, if that workspace is missing, moved or its
|
|
27
|
+
`workspace.json` id changed, and never picks another folder by itself.
|
|
28
|
+
`--init` always needs an explicit `--workspace`.
|
|
24
29
|
- Each backend's startup record (private token, launch id, port) is saved in
|
|
25
30
|
the state folder *before* the process is started. A later `open` reuses
|
|
26
31
|
that same attempt once it authenticates (also after a slow start), and
|
package/lib/cli.js
CHANGED
|
@@ -12,13 +12,20 @@ Usage:
|
|
|
12
12
|
workbite open --workspace ABS [--init] [--no-ui]
|
|
13
13
|
start or reuse this workspace's Work Stack owner and the
|
|
14
14
|
StackBite backend, then run the Work Stack terminal UI
|
|
15
|
+
workbite [open] [--no-ui] reopen the last workspace WorkBite opened successfully
|
|
15
16
|
workbite --version | --help
|
|
16
17
|
|
|
17
18
|
open:
|
|
18
|
-
--workspace ABS absolute workspace folder
|
|
19
|
-
--init create a new Work Stack workspace in a new or empty folder
|
|
19
|
+
--workspace ABS absolute workspace folder; always wins over the remembered one
|
|
20
|
+
--init create a new Work Stack workspace in a new or empty folder (needs --workspace)
|
|
20
21
|
--no-ui start or reuse the backends, print one JSON summary and exit
|
|
21
22
|
|
|
23
|
+
First time, name the workspace once:
|
|
24
|
+
workbite open --workspace /abs/path/new-folder --init
|
|
25
|
+
workbite open --workspace /abs/path/existing-workspace
|
|
26
|
+
Afterwards just run:
|
|
27
|
+
workbite
|
|
28
|
+
|
|
22
29
|
The Work Stack owner and StackBite backend keep running after the UI exits, so
|
|
23
30
|
agents continue. WorkBite never auto-updates, installs dependencies, approves
|
|
24
31
|
provider trust or stops processes it cannot prove are its own.
|
|
@@ -30,9 +37,9 @@ Locations (override with absolute WORKBITE_HOME / WORKBITE_STATE_DIR):
|
|
|
30
37
|
|
|
31
38
|
function parseArguments(argv) {
|
|
32
39
|
if (argv.length === 1 && ['--help', '-h', 'help'].includes(argv[0])) return { command: 'help' };
|
|
33
|
-
if (!argv.length) return { command: 'help' };
|
|
34
40
|
if (argv.length === 1 && ['--version', '-v', 'version'].includes(argv[0])) return { command: 'version' };
|
|
35
|
-
|
|
41
|
+
// Bare `workbite` (or only open options) means `workbite open`.
|
|
42
|
+
const [command, ...rest] = argv.length && !argv[0].startsWith('--') ? argv : ['open', ...argv];
|
|
36
43
|
if ((command === 'install' || command === 'check') && !rest.length) return { command };
|
|
37
44
|
if (command === 'open') {
|
|
38
45
|
const options = { command, init: false, ui: true };
|
|
@@ -44,7 +51,11 @@ function parseArguments(argv) {
|
|
|
44
51
|
&& !rest[index + 1].startsWith('--')) options.workspace = rest[++index];
|
|
45
52
|
else fail(`Unknown or repeated open option: ${String(argument).slice(0, 64)}. See workbite --help.`);
|
|
46
53
|
}
|
|
47
|
-
|
|
54
|
+
// Without --workspace the remembered workspace is reopened; --init never picks a folder by itself.
|
|
55
|
+
if (options.workspace === undefined) {
|
|
56
|
+
if (options.init) fail('--init needs --workspace ABS naming the new or empty folder');
|
|
57
|
+
return options;
|
|
58
|
+
}
|
|
48
59
|
if (!path.isAbsolute(options.workspace)) fail('--workspace must be an absolute path');
|
|
49
60
|
return options;
|
|
50
61
|
}
|
package/lib/install.js
CHANGED
|
@@ -49,16 +49,25 @@ function platformTarget(platform = process.platform, arch = process.arch, runtim
|
|
|
49
49
|
return target;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
const SEMVER = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/;
|
|
53
|
+
function canonicalVersion(value) { return typeof value === 'string' && SEMVER.test(value); }
|
|
54
|
+
|
|
55
|
+
// manifest.version names the sealed native artifact (asset, tag, URL, bundle, cache directory).
|
|
56
|
+
// An explicit launcherVersion lets a launcher-only npm patch reuse that artifact unchanged;
|
|
57
|
+
// without it the legacy binding (artifact version == package version) still applies.
|
|
58
|
+
function releaseSpec(manifest, platform = process.platform, arch = process.arch, runtime = {}, launcher = packageVersion) {
|
|
53
59
|
const target = platformTarget(platform, arch, runtime);
|
|
54
|
-
|
|
60
|
+
const bound = Object.hasOwn(manifest, 'launcherVersion')
|
|
61
|
+
? canonicalVersion(manifest.launcherVersion) && manifest.launcherVersion === launcher
|
|
62
|
+
: manifest.version === launcher;
|
|
63
|
+
if (manifest.schema !== 'workbite.release.v1' || manifest.product !== 'WorkBite' || !canonicalVersion(manifest.version) || !bound
|
|
55
64
|
|| manifest.repository !== REPOSITORY || manifest.tag !== `v${manifest.version}`) fail('Invalid pinned WorkBite release identity');
|
|
56
65
|
const spec = manifest.platforms?.[target];
|
|
57
66
|
const asset = `workbite-${manifest.version}-${target}${platform === 'win32' ? '.zip' : '.tar.gz'}`;
|
|
58
67
|
if (!spec || spec.asset !== asset
|
|
59
68
|
|| spec.url !== `https://github.com/${manifest.repository}/releases/download/${manifest.tag}/${asset}`) fail('Invalid pinned release asset');
|
|
60
69
|
if (spec.sha256 === null || spec.size === null || spec.bundleSha256 === null) {
|
|
61
|
-
fail(`This workbite ${
|
|
70
|
+
fail(`This workbite ${launcher} package has no sealed ${target} release artifact yet. Nothing was downloaded.`);
|
|
62
71
|
}
|
|
63
72
|
if (![spec.sha256, spec.bundleSha256].every(value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value))
|
|
64
73
|
|| !Number.isSafeInteger(spec.size) || spec.size <= 0 || spec.size > LIMITS.download) fail('Invalid pinned release asset');
|
package/lib/runtime.js
CHANGED
|
@@ -17,6 +17,7 @@ const BACKEND_SCHEMA = 'stackbite.workstack-backend.v1';
|
|
|
17
17
|
const CONNECTION_SCHEMA = 'workstack.helm-connection.v1';
|
|
18
18
|
const OWNER_SCHEMA = 'workbite.owner.v2';
|
|
19
19
|
const STACKBITE_SCHEMA = 'workbite.stackbite-backend.v2';
|
|
20
|
+
const SELECTION_SCHEMA = 'workbite.workspace-selection.v1';
|
|
20
21
|
const LOOPBACK = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{0,4})$/;
|
|
21
22
|
// Ports of the existing official StackBite and its previews are never chosen.
|
|
22
23
|
const RESERVED_PORTS = new Set([8089, 8090, 8091, 54036, 54039, 54236, 54239]);
|
|
@@ -46,7 +47,8 @@ function stateLayout(root) {
|
|
|
46
47
|
return { root, stackbite: path.join(root, 'stackbite'), workstackRuntime: path.join(root, 'workstack-runtime'),
|
|
47
48
|
journal: path.join(root, 'deploy-journal'), tuiState: path.join(root, 'workstack-tui'), owners: path.join(root, 'owners'),
|
|
48
49
|
logs: path.join(root, 'logs'), dock: path.join(root, 'execution-dock.json'),
|
|
49
|
-
stackbiteRecord: path.join(root, 'stackbite-backend.json'), lock: path.join(root, 'open.lock')
|
|
50
|
+
stackbiteRecord: path.join(root, 'stackbite-backend.json'), lock: path.join(root, 'open.lock'),
|
|
51
|
+
selection: path.join(root, 'last-workspace.json') };
|
|
50
52
|
}
|
|
51
53
|
async function prepareState(layout) {
|
|
52
54
|
for (const directory of [layout.root, layout.stackbite, layout.workstackRuntime, layout.journal, layout.tuiState,
|
|
@@ -118,6 +120,58 @@ async function initializeWorkspace(commands, workspace, environment) {
|
|
|
118
120
|
return uid;
|
|
119
121
|
}
|
|
120
122
|
|
|
123
|
+
// The workspace a bare `workbite` reopens: the last one opened successfully, or, for
|
|
124
|
+
// state from 0.1.0 with no saved selection, the single owner record it holds. It is
|
|
125
|
+
// never guessed (no CWD, mtime or default folder) and never silently replaced by
|
|
126
|
+
// another workspace; the genuine workspace.json id must still match.
|
|
127
|
+
const EXPLICIT = 'Open it once with: workbite open --workspace ABS';
|
|
128
|
+
async function currentUid(workspace, what) {
|
|
129
|
+
try { return await readWorkspaceUid(workspace); } catch (error) {
|
|
130
|
+
fail(`The ${what} ${workspace} is missing, moved or no longer a valid Work Stack workspace (${error.message}) ${EXPLICIT}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
async function rememberedWorkspace(layout) {
|
|
134
|
+
let saved;
|
|
135
|
+
try { saved = await readJson(layout.selection, 8192); } catch (error) {
|
|
136
|
+
if (error.code !== 'ENOENT') fail(`The saved workspace selection ${layout.selection} is unreadable. ${EXPLICIT}`);
|
|
137
|
+
return ownerWorkspace(layout);
|
|
138
|
+
}
|
|
139
|
+
if (saved === null || typeof saved !== 'object' || Array.isArray(saved) || Object.keys(saved).length !== 3
|
|
140
|
+
|| saved.schema !== SELECTION_SCHEMA || typeof saved.workspace !== 'string' || !path.isAbsolute(saved.workspace)
|
|
141
|
+
|| typeof saved.workspace_uid !== 'string' || !UUID.test(saved.workspace_uid)) {
|
|
142
|
+
fail(`The saved workspace selection ${layout.selection} is invalid. ${EXPLICIT}`);
|
|
143
|
+
}
|
|
144
|
+
if (await currentUid(saved.workspace, 'last opened workspace') !== saved.workspace_uid) {
|
|
145
|
+
fail(`The last opened workspace ${saved.workspace} was replaced (its workspace.json id changed); WorkBite does not open it silently. ${EXPLICIT}`);
|
|
146
|
+
}
|
|
147
|
+
return { workspace: saved.workspace, uid: saved.workspace_uid };
|
|
148
|
+
}
|
|
149
|
+
async function ownerWorkspace(layout) {
|
|
150
|
+
let names = [];
|
|
151
|
+
try { names = (await fs.readdir(layout.owners)).filter(name => /^[0-9a-f]{24}\.json$/.test(name)); } catch (error) {
|
|
152
|
+
if (error.code !== 'ENOENT') fail(`WorkBite owner records are unreadable. ${EXPLICIT}`);
|
|
153
|
+
}
|
|
154
|
+
if (!names.length) fail(`No workspace has been opened yet. ${EXPLICIT} (add --init for a new or empty folder).`);
|
|
155
|
+
if (names.length > 1) fail(`WorkBite has ${names.length} workspace owner records and will not guess which one to open. ${EXPLICIT}`);
|
|
156
|
+
const file = path.join(layout.owners, names[0]);
|
|
157
|
+
let record;
|
|
158
|
+
try { record = await readJson(file); } catch { fail(`The Work Stack owner record ${file} is unreadable. ${EXPLICIT}`); }
|
|
159
|
+
if (!isRecord(record) || record.schema !== OWNER_SCHEMA || typeof record.workspace !== 'string' || !path.isAbsolute(record.workspace)
|
|
160
|
+
|| `${workspaceKey(record.workspace)}.json` !== names[0] || typeof record.workspace_uid !== 'string' || !UUID.test(record.workspace_uid)) {
|
|
161
|
+
fail(`The Work Stack owner record ${file} is not a valid owner record. ${EXPLICIT}`);
|
|
162
|
+
}
|
|
163
|
+
if (await currentUid(record.workspace, 'workspace recorded in the owner record') !== record.workspace_uid) {
|
|
164
|
+
fail(`The workspace recorded in ${file} was replaced (its workspace.json id changed); WorkBite does not open it silently. ${EXPLICIT}`);
|
|
165
|
+
}
|
|
166
|
+
return { workspace: record.workspace, uid: record.workspace_uid };
|
|
167
|
+
}
|
|
168
|
+
async function rememberWorkspace(layout, workspace, uid) {
|
|
169
|
+
const bytes = Buffer.from(JSON.stringify({ schema: SELECTION_SCHEMA, workspace, workspace_uid: uid }, null, 2) + '\n');
|
|
170
|
+
let current = null;
|
|
171
|
+
try { current = await readSmall(layout.selection, 8192); } catch { current = null; }
|
|
172
|
+
if (!current || !current.equals(bytes)) await writeAtomic(layout.selection, bytes);
|
|
173
|
+
}
|
|
174
|
+
|
|
121
175
|
function request(url, { method = 'GET', headers = {}, body, timeout = 5000 } = {}) {
|
|
122
176
|
const match = LOOPBACK.exec(url.origin);
|
|
123
177
|
if (!match) return Promise.reject(new Error('not a loopback origin'));
|
|
@@ -370,18 +424,27 @@ function runForeground(command, args, environment, { spawnImpl = spawn, signalSo
|
|
|
370
424
|
// commands maps each bundle executable to its argv prefix; tests substitute fixtures.
|
|
371
425
|
async function openSuite({ installed, commands, workspace, init = false, stateRoot, environment = process.env,
|
|
372
426
|
ui = true, spawnImpl = spawn, stdout = process.stdout, timeouts = {} }) {
|
|
373
|
-
workspace = checkedWorkspace(workspace);
|
|
427
|
+
if (workspace !== undefined) workspace = checkedWorkspace(workspace);
|
|
428
|
+
else if (init) fail('--init needs --workspace ABS naming the new or empty folder');
|
|
374
429
|
commands = commands || Object.fromEntries(Object.entries(installed.executables).map(([name, file]) => [name, [file]]));
|
|
375
430
|
const layout = stateLayout(path.resolve(stateRoot));
|
|
376
431
|
await prepareState(layout);
|
|
377
432
|
const base = cleanEnvironment(environment);
|
|
378
433
|
const engineEnvironment = { ...base, WORK_STACK_RUNTIME: layout.workstackRuntime };
|
|
379
434
|
const session = await withLock(layout.lock, async () => {
|
|
435
|
+
let remembered = null;
|
|
436
|
+
if (workspace === undefined) {
|
|
437
|
+
remembered = await rememberedWorkspace(layout);
|
|
438
|
+
workspace = checkedWorkspace(remembered.workspace);
|
|
439
|
+
}
|
|
380
440
|
const uid = init ? await initializeWorkspace(commands, workspace, engineEnvironment) : await readWorkspaceUid(workspace);
|
|
441
|
+
if (remembered && uid !== remembered.uid) fail(`The workspace ${workspace} changed while opening. ${EXPLICIT}`);
|
|
381
442
|
const backendId = await ensureBackendId(layout);
|
|
382
443
|
const engine = await ensureEngine({ commands, workspace, uid, layout, environment: engineEnvironment, spawnImpl, timeout: timeouts.engine });
|
|
383
444
|
const stackbite = await ensureStackbite({ commands, layout, backendId, environment: base, spawnImpl, timeout: timeouts.stackbite });
|
|
384
445
|
await writeDockConfig(layout, stackbite.url);
|
|
446
|
+
// Only after the owner, StackBite and dock config are proven ready, and before the UI.
|
|
447
|
+
await rememberWorkspace(layout, workspace, uid);
|
|
385
448
|
return { uid, backendId, engine, stackbite };
|
|
386
449
|
});
|
|
387
450
|
const summary = { schema: 'workbite.open.v1', workspace, workspace_uid: session.uid, endpoint: session.engine.endpoint,
|
|
@@ -399,4 +462,4 @@ async function openSuite({ installed, commands, workspace, init = false, stateRo
|
|
|
399
462
|
|
|
400
463
|
module.exports = { cleanEnvironment, stateLayout, prepareState, ensureBackendId, workspaceKey, readWorkspaceUid,
|
|
401
464
|
initializeWorkspace, engineIdentity, stackbiteIdentity, freePort, ensureEngine, ensureStackbite, writeDockConfig,
|
|
402
|
-
withLock, uiEnvironment, openSuite, RESERVED_PORTS };
|
|
465
|
+
withLock, uiEnvironment, openSuite, rememberedWorkspace, RESERVED_PORTS, SELECTION_SCHEMA };
|
package/package.json
CHANGED