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 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 open --workspace /abs/path/existing-workspace
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
- const [command, ...rest] = argv;
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
- if (options.workspace === undefined) fail('workbite open needs --workspace ABS');
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
- function releaseSpec(manifest, platform = process.platform, arch = process.arch, runtime = {}) {
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
- if (manifest.schema !== 'workbite.release.v1' || manifest.product !== 'WorkBite' || manifest.version !== packageVersion
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 ${manifest.version} package has no sealed ${target} release artifact yet. Nothing was downloaded.`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workbite",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "WorkBite (Work Stack + StackBite) verified native suite installer and launcher",
5
5
  "license": "UNLICENSED",
6
6
  "engines": {
package/release.json CHANGED
@@ -2,6 +2,7 @@
2
2
  "schema": "workbite.release.v1",
3
3
  "product": "WorkBite",
4
4
  "version": "0.1.0",
5
+ "launcherVersion": "0.1.1",
5
6
  "repository": "Shinick-Han/WorkBite-releases",
6
7
  "tag": "v0.1.0",
7
8
  "platforms": {