@steve31415/baselib 3.47.1 → 3.48.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 +12 -2
- package/dist/bin/pw-code-manifest.d.ts +2 -0
- package/dist/bin/pw-code-manifest.js +9 -0
- package/dist/client-version.d.ts +52 -0
- package/dist/client-version.js +54 -0
- package/dist/code-manifest.d.ts +37 -0
- package/dist/code-manifest.js +176 -0
- package/dist/deploy/deploy-marker.d.ts +5 -0
- package/dist/deploy/deploy-marker.js +63 -0
- package/dist/deploy/deploy.js +4 -0
- package/dist/extension-self-update.d.ts +75 -0
- package/dist/extension-self-update.js +178 -0
- package/package.json +15 -2
package/README.md
CHANGED
|
@@ -6,8 +6,9 @@ What it provides and why: `docs/SPEC.md`. How it's put together:
|
|
|
6
6
|
`~/migration/research/base-services-design.md`.
|
|
7
7
|
|
|
8
8
|
Server subpath exports: `config`, `log`, `auth`, `oauth-resource`, `connector`, `s2s`, `db`, `http`, `sync`,
|
|
9
|
-
`app-update`, `llm`, `email`, `twitter`,
|
|
10
|
-
`slow-fetch`, `sync-browser`,
|
|
9
|
+
`app-update`, `llm`, `email`, `twitter`, `hub` (the connection hub's server core and app notifier), `code-manifest` and
|
|
10
|
+
`client-version`. Browser exports: `log-browser`, `rum`, `slow-fetch`, `sync-browser`, `app-update-browser`, and
|
|
11
|
+
`extension-self-update`.
|
|
11
12
|
|
|
12
13
|
`sync` provides the Postgres event-log and server protocol primitives;
|
|
13
14
|
`sync-browser` provides the IndexedDB outbox and offline-first client.
|
|
@@ -36,6 +37,15 @@ app's hooks module (`DeployHooks` in `src/deploy/types.ts`) extends both,
|
|
|
36
37
|
first step of any production investigation (same doc, "Investigating
|
|
37
38
|
production state").
|
|
38
39
|
|
|
40
|
+
Mac-run code (`~/plasticine-way/mac/sync/DESIGN.md`): `code-manifest` and the
|
|
41
|
+
`pw-code-manifest` bin write and check a directory's `mac-code.json` (its
|
|
42
|
+
tracked files' sha256 and one id); `extension-self-update` reloads an unpacked
|
|
43
|
+
extension into code that verifies on disk and that its server says it
|
|
44
|
+
deployed; `client-version` counts how long a Mac-run client has stayed behind
|
|
45
|
+
while reporting continuously, for the "stayed behind" WARN. `pw-deploy` marks
|
|
46
|
+
a running deploy in the git common dir (`pw-deploy.lock`) so the Mac's sync
|
|
47
|
+
job does not fast-forward the checkout under the build.
|
|
48
|
+
|
|
39
49
|
`twitter` provides Mirror-first tweet lookup, TwitterAPI.io batch/search/
|
|
40
50
|
article calls, and thread assembly through direct reply edges. It includes
|
|
41
51
|
one level of quotes, article bodies and URL expansions. Use one shared
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `pw-code-manifest` — write or check a directory's mac-code.json (../code-manifest.ts;
|
|
3
|
+
// plasticine-way mac/sync/DESIGN.md §3.3).
|
|
4
|
+
//
|
|
5
|
+
// pw-code-manifest write <dir> [--exclude <pattern>]... regenerate it
|
|
6
|
+
// pw-code-manifest check <dir> [--exclude <pattern>]... exit 1 when it is stale (run by verify)
|
|
7
|
+
// pw-code-manifest id <dir> [--exclude <pattern>]... print the id of the files as they are now
|
|
8
|
+
import { runCodeManifestCli } from '../code-manifest.js';
|
|
9
|
+
process.exitCode = await runCodeManifestCli(process.argv.slice(2), process.cwd());
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Behind for this long, counted as above, before the first WARN. Expected is under 15 minutes. */
|
|
2
|
+
export declare const BEHIND_WARN_MS: number;
|
|
3
|
+
/** Further counted time before the WARN repeats. */
|
|
4
|
+
export declare const BEHIND_REWARN_MS: number;
|
|
5
|
+
export interface BehindState {
|
|
6
|
+
/** Counted time behind in this episode. */
|
|
7
|
+
behindMs: number;
|
|
8
|
+
/** Server time of the last report in this episode. */
|
|
9
|
+
lastAt: number;
|
|
10
|
+
/** behindMs at the last WARN in this episode, or null before the first. */
|
|
11
|
+
warnedAtMs: number | null;
|
|
12
|
+
}
|
|
13
|
+
export type BehindCause = 'switch_not_taken' | 'checkout_not_current';
|
|
14
|
+
/**
|
|
15
|
+
* Why a client is behind, or null when it is not. `loaded`: the code the client
|
|
16
|
+
* runs (null or absent when it does not report one, e.g. a version from before
|
|
17
|
+
* code reports, which counts as behind). `disk`: the verified code on the Mac's
|
|
18
|
+
* disk (null when it does not verify). `shipped`: the code this server deployed.
|
|
19
|
+
*/
|
|
20
|
+
export declare function behindCause(report: {
|
|
21
|
+
loaded: string | null | undefined;
|
|
22
|
+
disk: string | null | undefined;
|
|
23
|
+
shipped: string;
|
|
24
|
+
}): BehindCause | null;
|
|
25
|
+
/** The gap cap for a client that reports every `intervalMs`. */
|
|
26
|
+
export declare function gapCapFor(intervalMs: number): number;
|
|
27
|
+
/** Fold one report into the episode. Returns null when the client is not behind (the episode ends). */
|
|
28
|
+
export declare function accrueBehind(prev: BehindState | null, report: {
|
|
29
|
+
behind: boolean;
|
|
30
|
+
now: number;
|
|
31
|
+
gapCapMs: number;
|
|
32
|
+
}): BehindState | null;
|
|
33
|
+
/** Whether this state warrants a WARN now; when it does, the returned state records it. */
|
|
34
|
+
export declare function shouldWarn(state: BehindState, limits?: {
|
|
35
|
+
thresholdMs?: number;
|
|
36
|
+
rewarnMs?: number;
|
|
37
|
+
}): {
|
|
38
|
+
warn: boolean;
|
|
39
|
+
state: BehindState;
|
|
40
|
+
};
|
|
41
|
+
/** accrueBehind then shouldWarn: the one call an app makes per report. */
|
|
42
|
+
export declare function recordBehind(prev: BehindState | null, report: {
|
|
43
|
+
behind: boolean;
|
|
44
|
+
now: number;
|
|
45
|
+
intervalMs: number;
|
|
46
|
+
}, limits?: {
|
|
47
|
+
thresholdMs?: number;
|
|
48
|
+
rewarnMs?: number;
|
|
49
|
+
}): {
|
|
50
|
+
state: BehindState | null;
|
|
51
|
+
warn: boolean;
|
|
52
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// "Stayed behind" accounting for code that runs on Steve's Mac (plasticine-way
|
|
2
|
+
// mac/sync/DESIGN.md §3.6). Pure: each app persists the state per device and
|
|
3
|
+
// calls this from the request that carries the device's report.
|
|
4
|
+
//
|
|
5
|
+
// A client is behind when the code it runs is not the code the server
|
|
6
|
+
// deployed. The time that counts is time the client spent behind while
|
|
7
|
+
// REPORTING CONTINUOUSLY: a report adds the gap since the previous one only
|
|
8
|
+
// when that gap is at most `gapCapMs` (1.5 x the client's report interval).
|
|
9
|
+
// A laptop asleep, offline or travelling contributes nothing, however long
|
|
10
|
+
// (Steve, 2026-11-01: the laptop can easily be offline for 12 hours or more).
|
|
11
|
+
// All times are the server's clock.
|
|
12
|
+
/** Behind for this long, counted as above, before the first WARN. Expected is under 15 minutes. */
|
|
13
|
+
export const BEHIND_WARN_MS = 4 * 60 * 60 * 1000;
|
|
14
|
+
/** Further counted time before the WARN repeats. */
|
|
15
|
+
export const BEHIND_REWARN_MS = 24 * 60 * 60 * 1000;
|
|
16
|
+
/**
|
|
17
|
+
* Why a client is behind, or null when it is not. `loaded`: the code the client
|
|
18
|
+
* runs (null or absent when it does not report one, e.g. a version from before
|
|
19
|
+
* code reports, which counts as behind). `disk`: the verified code on the Mac's
|
|
20
|
+
* disk (null when it does not verify). `shipped`: the code this server deployed.
|
|
21
|
+
*/
|
|
22
|
+
export function behindCause(report) {
|
|
23
|
+
if (report.loaded === report.shipped)
|
|
24
|
+
return null;
|
|
25
|
+
return report.disk === report.shipped ? 'switch_not_taken' : 'checkout_not_current';
|
|
26
|
+
}
|
|
27
|
+
/** The gap cap for a client that reports every `intervalMs`. */
|
|
28
|
+
export function gapCapFor(intervalMs) {
|
|
29
|
+
return Math.round(intervalMs * 1.5);
|
|
30
|
+
}
|
|
31
|
+
/** Fold one report into the episode. Returns null when the client is not behind (the episode ends). */
|
|
32
|
+
export function accrueBehind(prev, report) {
|
|
33
|
+
if (!report.behind)
|
|
34
|
+
return null;
|
|
35
|
+
if (!prev)
|
|
36
|
+
return { behindMs: 0, lastAt: report.now, warnedAtMs: null };
|
|
37
|
+
const gap = report.now - prev.lastAt;
|
|
38
|
+
const counted = gap > 0 && gap <= report.gapCapMs ? gap : 0;
|
|
39
|
+
return { behindMs: prev.behindMs + counted, lastAt: Math.max(prev.lastAt, report.now), warnedAtMs: prev.warnedAtMs };
|
|
40
|
+
}
|
|
41
|
+
/** Whether this state warrants a WARN now; when it does, the returned state records it. */
|
|
42
|
+
export function shouldWarn(state, limits = {}) {
|
|
43
|
+
const threshold = limits.thresholdMs ?? BEHIND_WARN_MS;
|
|
44
|
+
const rewarn = limits.rewarnMs ?? BEHIND_REWARN_MS;
|
|
45
|
+
const due = state.warnedAtMs === null ? state.behindMs >= threshold : state.behindMs - state.warnedAtMs >= rewarn;
|
|
46
|
+
return due ? { warn: true, state: { ...state, warnedAtMs: state.behindMs } } : { warn: false, state };
|
|
47
|
+
}
|
|
48
|
+
/** accrueBehind then shouldWarn: the one call an app makes per report. */
|
|
49
|
+
export function recordBehind(prev, report, limits = {}) {
|
|
50
|
+
const state = accrueBehind(prev, { behind: report.behind, now: report.now, gapCapMs: gapCapFor(report.intervalMs) });
|
|
51
|
+
if (!state)
|
|
52
|
+
return { state: null, warn: false };
|
|
53
|
+
return shouldWarn(state, limits);
|
|
54
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
export declare const CODE_MANIFEST_FILE = "mac-code.json";
|
|
2
|
+
export interface CodeManifest {
|
|
3
|
+
id: string;
|
|
4
|
+
files: Record<string, string>;
|
|
5
|
+
}
|
|
6
|
+
export interface CodeManifestOptions {
|
|
7
|
+
/** Paths to leave out, relative to the directory: `dir/` (a prefix), or a
|
|
8
|
+
* glob where `*` matches within one path segment and `**` across segments. */
|
|
9
|
+
exclude?: string[];
|
|
10
|
+
}
|
|
11
|
+
/** The id over a file list (the browser twin is in extension-self-update). */
|
|
12
|
+
export declare function codeIdOf(files: Record<string, string>): string;
|
|
13
|
+
export declare function isExcluded(path: string, exclude?: string[]): boolean;
|
|
14
|
+
/** Build the manifest for `dir` from git's tracked files as they are in the work tree. */
|
|
15
|
+
export declare function buildCodeManifest(dir: string, options?: CodeManifestOptions): Promise<CodeManifest>;
|
|
16
|
+
export declare function serializeCodeManifest(manifest: CodeManifest): string;
|
|
17
|
+
export declare function writeCodeManifest(dir: string, options?: CodeManifestOptions): Promise<CodeManifest>;
|
|
18
|
+
export interface CodeManifestCheck {
|
|
19
|
+
ok: boolean;
|
|
20
|
+
/** Paths added, removed or changed since the committed manifest was written. */
|
|
21
|
+
changed: string[];
|
|
22
|
+
committedId: string | null;
|
|
23
|
+
currentId: string;
|
|
24
|
+
}
|
|
25
|
+
/** Is the committed manifest current? (Run by each repo's verify.) */
|
|
26
|
+
export declare function checkCodeManifest(dir: string, options?: CodeManifestOptions): Promise<CodeManifestCheck>;
|
|
27
|
+
/** The committed manifest in `dir`, or null when absent or malformed. */
|
|
28
|
+
export declare function readCodeManifest(dir: string): Promise<CodeManifest | null>;
|
|
29
|
+
/**
|
|
30
|
+
* For a Node consumer reading its own directory (no git needed): the
|
|
31
|
+
* manifest's id when every listed file is present and matches and the id is
|
|
32
|
+
* the id of the list; null otherwise (a checkout in progress, a local edit, a
|
|
33
|
+
* missing or hand-edited manifest).
|
|
34
|
+
*/
|
|
35
|
+
export declare function verifyCodeManifestOnDisk(dir: string): Promise<string | null>;
|
|
36
|
+
/** The `pw-code-manifest` command (src/bin/pw-code-manifest.ts). Returns the exit code. */
|
|
37
|
+
export declare function runCodeManifestCli(argv: string[], cwd: string, out?: (line: string) => void, err?: (line: string) => void): Promise<number>;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// Code manifests: a committed list of a directory's files and their sha256, with
|
|
2
|
+
// one id over the list (plasticine-way mac/sync/DESIGN.md §3.3).
|
|
3
|
+
//
|
|
4
|
+
// Steve's Mac runs extensions and daemons straight from git checkouts. Each of
|
|
5
|
+
// those consumers switches to new code only when the code on disk verifies
|
|
6
|
+
// against `mac-code.json` AND its id is the one the app's server says it
|
|
7
|
+
// deployed. So the id must be computable three ways that agree: here (the
|
|
8
|
+
// generator, the verify-time check, and server builds), in the browser
|
|
9
|
+
// (`extension-self-update`), and in a Node consumer reading its own files
|
|
10
|
+
// (`verifyCodeManifestOnDisk`).
|
|
11
|
+
//
|
|
12
|
+
// The id is sha256 over the sorted lines `<path>\0<sha256>\n`, first 16 hex
|
|
13
|
+
// characters. Paths are relative to the directory, `/`-separated, and are
|
|
14
|
+
// git's tracked files only, so untracked leftovers (.DS_Store, *.orig) never
|
|
15
|
+
// change it. The manifest file itself is never listed.
|
|
16
|
+
import { execFile } from 'node:child_process';
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
import { readFile, rename, writeFile } from 'node:fs/promises';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
export const CODE_MANIFEST_FILE = 'mac-code.json';
|
|
21
|
+
function sha256Hex(data) {
|
|
22
|
+
return createHash('sha256').update(data).digest('hex');
|
|
23
|
+
}
|
|
24
|
+
/** The id over a file list (the browser twin is in extension-self-update). */
|
|
25
|
+
export function codeIdOf(files) {
|
|
26
|
+
const lines = Object.keys(files)
|
|
27
|
+
.sort()
|
|
28
|
+
.map((path) => `${path}\0${files[path]}\n`)
|
|
29
|
+
.join('');
|
|
30
|
+
return sha256Hex(lines).slice(0, 16);
|
|
31
|
+
}
|
|
32
|
+
function globToRegExp(glob) {
|
|
33
|
+
let out = '';
|
|
34
|
+
for (let i = 0; i < glob.length; i++) {
|
|
35
|
+
const c = glob[i];
|
|
36
|
+
if (c === '*' && glob[i + 1] === '*') {
|
|
37
|
+
out += '.*';
|
|
38
|
+
i++;
|
|
39
|
+
if (glob[i + 1] === '/')
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
else if (c === '*') {
|
|
43
|
+
out += '[^/]*';
|
|
44
|
+
}
|
|
45
|
+
else {
|
|
46
|
+
out += c.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return new RegExp(`^${out}$`);
|
|
50
|
+
}
|
|
51
|
+
export function isExcluded(path, exclude = []) {
|
|
52
|
+
return exclude.some((pattern) => pattern.endsWith('/') ? path.startsWith(pattern) : globToRegExp(pattern).test(path));
|
|
53
|
+
}
|
|
54
|
+
function gitLsFiles(dir) {
|
|
55
|
+
return new Promise((resolve, reject) => {
|
|
56
|
+
execFile('git', ['ls-files', '-z', '--', '.'], { cwd: dir, maxBuffer: 64 * 1024 * 1024 }, (error, stdout) => {
|
|
57
|
+
if (error)
|
|
58
|
+
reject(new Error(`git ls-files failed in ${dir}: ${error.message}`));
|
|
59
|
+
else
|
|
60
|
+
resolve(stdout.split('\0').filter((p) => p !== ''));
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/** Build the manifest for `dir` from git's tracked files as they are in the work tree. */
|
|
65
|
+
export async function buildCodeManifest(dir, options = {}) {
|
|
66
|
+
const paths = (await gitLsFiles(dir))
|
|
67
|
+
.filter((path) => path !== CODE_MANIFEST_FILE && !isExcluded(path, options.exclude))
|
|
68
|
+
.sort();
|
|
69
|
+
const files = {};
|
|
70
|
+
for (const path of paths)
|
|
71
|
+
files[path] = sha256Hex(await readFile(join(dir, path)));
|
|
72
|
+
return { id: codeIdOf(files), files };
|
|
73
|
+
}
|
|
74
|
+
export function serializeCodeManifest(manifest) {
|
|
75
|
+
return `${JSON.stringify({ id: manifest.id, files: manifest.files }, null, 2)}\n`;
|
|
76
|
+
}
|
|
77
|
+
export async function writeCodeManifest(dir, options = {}) {
|
|
78
|
+
const manifest = await buildCodeManifest(dir, options);
|
|
79
|
+
const target = join(dir, CODE_MANIFEST_FILE);
|
|
80
|
+
await writeFile(`${target}.tmp`, serializeCodeManifest(manifest));
|
|
81
|
+
await rename(`${target}.tmp`, target);
|
|
82
|
+
return manifest;
|
|
83
|
+
}
|
|
84
|
+
/** Is the committed manifest current? (Run by each repo's verify.) */
|
|
85
|
+
export async function checkCodeManifest(dir, options = {}) {
|
|
86
|
+
const current = await buildCodeManifest(dir, options);
|
|
87
|
+
const committed = await readCodeManifest(dir);
|
|
88
|
+
const before = committed?.files ?? {};
|
|
89
|
+
const changed = [...new Set([...Object.keys(before), ...Object.keys(current.files)])]
|
|
90
|
+
.filter((path) => before[path] !== current.files[path])
|
|
91
|
+
.sort();
|
|
92
|
+
return { ok: committed !== null && changed.length === 0 && committed.id === current.id, changed, committedId: committed?.id ?? null, currentId: current.id };
|
|
93
|
+
}
|
|
94
|
+
/** The committed manifest in `dir`, or null when absent or malformed. */
|
|
95
|
+
export async function readCodeManifest(dir) {
|
|
96
|
+
let raw;
|
|
97
|
+
try {
|
|
98
|
+
raw = await readFile(join(dir, CODE_MANIFEST_FILE), 'utf8');
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
const parsed = JSON.parse(raw);
|
|
105
|
+
if (!parsed || typeof parsed !== 'object')
|
|
106
|
+
return null;
|
|
107
|
+
const { id, files } = parsed;
|
|
108
|
+
if (typeof id !== 'string' || !files || typeof files !== 'object')
|
|
109
|
+
return null;
|
|
110
|
+
for (const value of Object.values(files))
|
|
111
|
+
if (typeof value !== 'string')
|
|
112
|
+
return null;
|
|
113
|
+
return { id, files: files };
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* For a Node consumer reading its own directory (no git needed): the
|
|
121
|
+
* manifest's id when every listed file is present and matches and the id is
|
|
122
|
+
* the id of the list; null otherwise (a checkout in progress, a local edit, a
|
|
123
|
+
* missing or hand-edited manifest).
|
|
124
|
+
*/
|
|
125
|
+
export async function verifyCodeManifestOnDisk(dir) {
|
|
126
|
+
const manifest = await readCodeManifest(dir);
|
|
127
|
+
if (!manifest || codeIdOf(manifest.files) !== manifest.id)
|
|
128
|
+
return null;
|
|
129
|
+
for (const [path, expected] of Object.entries(manifest.files)) {
|
|
130
|
+
let bytes;
|
|
131
|
+
try {
|
|
132
|
+
bytes = await readFile(join(dir, path));
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
if (sha256Hex(bytes) !== expected)
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
return manifest.id;
|
|
141
|
+
}
|
|
142
|
+
/** The `pw-code-manifest` command (src/bin/pw-code-manifest.ts). Returns the exit code. */
|
|
143
|
+
export async function runCodeManifestCli(argv, cwd, out = (line) => console.log(line), err = (line) => console.error(line)) {
|
|
144
|
+
const [command, dirArg, ...rest] = argv;
|
|
145
|
+
const exclude = [];
|
|
146
|
+
for (let i = 0; i < rest.length; i++) {
|
|
147
|
+
if (rest[i] === '--exclude' && rest[i + 1] !== undefined)
|
|
148
|
+
exclude.push(rest[++i]);
|
|
149
|
+
else {
|
|
150
|
+
err(`pw-code-manifest: unexpected argument ${rest[i]}`);
|
|
151
|
+
return 2;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!dirArg || !['write', 'check', 'id'].includes(command ?? '')) {
|
|
155
|
+
err('usage: pw-code-manifest write|check|id <dir> [--exclude <pattern>]...');
|
|
156
|
+
return 2;
|
|
157
|
+
}
|
|
158
|
+
const dir = join(cwd, dirArg);
|
|
159
|
+
if (command === 'write') {
|
|
160
|
+
const manifest = await writeCodeManifest(dir, { exclude });
|
|
161
|
+
out(`${join(dirArg, CODE_MANIFEST_FILE)}: ${manifest.id} (${Object.keys(manifest.files).length} files)`);
|
|
162
|
+
return 0;
|
|
163
|
+
}
|
|
164
|
+
if (command === 'id') {
|
|
165
|
+
out((await buildCodeManifest(dir, { exclude })).id);
|
|
166
|
+
return 0;
|
|
167
|
+
}
|
|
168
|
+
const check = await checkCodeManifest(dir, { exclude });
|
|
169
|
+
if (check.ok) {
|
|
170
|
+
out(`${join(dirArg, CODE_MANIFEST_FILE)} is current (${check.currentId})`);
|
|
171
|
+
return 0;
|
|
172
|
+
}
|
|
173
|
+
err(`${join(dirArg, CODE_MANIFEST_FILE)} is stale${check.committedId ? '' : ' or missing'}: ${check.changed.slice(0, 20).join(', ') || '(id mismatch)'}`);
|
|
174
|
+
err(`Regenerate it: npx pw-code-manifest write ${dirArg}${exclude.map((p) => ` --exclude '${p}'`).join('')}`);
|
|
175
|
+
return 1;
|
|
176
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const DEPLOY_MARKER_FILE = "pw-deploy.lock";
|
|
2
|
+
/** The git common dir of `repoRoot`, without running git; null outside a checkout. */
|
|
3
|
+
export declare function gitCommonDir(repoRoot: string): Promise<string | null>;
|
|
4
|
+
/** Write the marker; returns a remover (a no-op outside a git checkout). Never throws. */
|
|
5
|
+
export declare function writeDeployMarker(repoRoot: string, now?: Date): Promise<() => Promise<void>>;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// The "a deploy is running from this checkout" marker (plasticine-way
|
|
2
|
+
// mac/sync/DESIGN.md §3.2).
|
|
3
|
+
//
|
|
4
|
+
// pw-deploy checks the tree once at preflight and then builds from the live
|
|
5
|
+
// work tree for minutes. On Steve's Mac a LaunchAgent fast-forwards the main
|
|
6
|
+
// checkouts of Mac-run repos (the containers' ~ is that folder), which could
|
|
7
|
+
// change files under a running build. So pw-deploy writes this marker into the
|
|
8
|
+
// git common dir — shared by the Mac's git and the containers', never part of
|
|
9
|
+
// the work tree — and removes it when the deploy ends; the job skips a repo
|
|
10
|
+
// whose marker is younger than 2 hours.
|
|
11
|
+
import { readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
12
|
+
import { hostname } from 'node:os';
|
|
13
|
+
import { dirname, isAbsolute, join, resolve } from 'node:path';
|
|
14
|
+
export const DEPLOY_MARKER_FILE = 'pw-deploy.lock';
|
|
15
|
+
/** The git common dir of `repoRoot`, without running git; null outside a checkout. */
|
|
16
|
+
export async function gitCommonDir(repoRoot) {
|
|
17
|
+
const dotGit = join(repoRoot, '.git');
|
|
18
|
+
let info;
|
|
19
|
+
try {
|
|
20
|
+
info = await stat(dotGit);
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
if (info.isDirectory())
|
|
26
|
+
return dotGit;
|
|
27
|
+
// A worktree: `.git` is a file "gitdir: <path>", and that dir names the common dir in `commondir`.
|
|
28
|
+
const pointer = (await readFile(dotGit, 'utf8')).match(/^gitdir:\s*(.+)\s*$/m)?.[1];
|
|
29
|
+
if (!pointer)
|
|
30
|
+
return null;
|
|
31
|
+
const gitDir = isAbsolute(pointer) ? pointer : resolve(repoRoot, pointer);
|
|
32
|
+
try {
|
|
33
|
+
const common = (await readFile(join(gitDir, 'commondir'), 'utf8')).trim();
|
|
34
|
+
return isAbsolute(common) ? common : resolve(gitDir, common);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return dirname(dirname(gitDir));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/** Write the marker; returns a remover (a no-op outside a git checkout). Never throws. */
|
|
41
|
+
export async function writeDeployMarker(repoRoot, now = new Date()) {
|
|
42
|
+
const dir = await gitCommonDir(repoRoot).catch(() => null);
|
|
43
|
+
if (!dir)
|
|
44
|
+
return async () => { };
|
|
45
|
+
const path = join(dir, DEPLOY_MARKER_FILE);
|
|
46
|
+
const body = `${JSON.stringify({ pid: process.pid, host: hostname(), repo: repoRoot, startedAt: now.toISOString() })}\n`;
|
|
47
|
+
try {
|
|
48
|
+
await writeFile(path, body);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return async () => { };
|
|
52
|
+
}
|
|
53
|
+
return async () => {
|
|
54
|
+
// Remove only our own marker: a second deploy may have replaced it.
|
|
55
|
+
try {
|
|
56
|
+
if ((await readFile(path, 'utf8')) === body)
|
|
57
|
+
await rm(path, { force: true });
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// already gone
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
package/dist/deploy/deploy.js
CHANGED
|
@@ -13,6 +13,7 @@ import { must, mustJson } from './exec.js';
|
|
|
13
13
|
import { openEvidence, PhaseTimer } from './evidence.js';
|
|
14
14
|
import { acquireLock, clearHold, publishRetainedAssets, readHold, readReleaseRecord, releaseLock, verifyLock, writeReleaseRecord, } from './gcs.js';
|
|
15
15
|
import { consistentBaseSha, readServingState, SHA40 } from './plan.js';
|
|
16
|
+
import { writeDeployMarker } from './deploy-marker.js';
|
|
16
17
|
const RELEASE_DEADLINE_MS = 25 * 60_000;
|
|
17
18
|
const IMAGE_DIGEST = /@sha256:[0-9a-f]{64}$/;
|
|
18
19
|
const MAX_DIRTY_PATHS = 20;
|
|
@@ -40,6 +41,8 @@ export async function runDeploy(options) {
|
|
|
40
41
|
let trafficMoved = false;
|
|
41
42
|
let evidenceDir = '';
|
|
42
43
|
let lock = null;
|
|
44
|
+
// Tells the Mac's checkout-sync job not to fast-forward this checkout under the build (deploy-marker.ts).
|
|
45
|
+
const removeDeployMarker = await writeDeployMarker(options.repoRoot);
|
|
43
46
|
try {
|
|
44
47
|
// ---- preflight ----
|
|
45
48
|
const config = await loadConfig(options.repoRoot);
|
|
@@ -440,5 +443,6 @@ export async function runDeploy(options) {
|
|
|
440
443
|
finally {
|
|
441
444
|
if (lock)
|
|
442
445
|
await releaseLock(runner, lock);
|
|
446
|
+
await removeDeployMarker();
|
|
443
447
|
}
|
|
444
448
|
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export declare const STABLE_MS: number;
|
|
2
|
+
export declare const RETRY_MS: number;
|
|
3
|
+
/**
|
|
4
|
+
* How long one renewal keeps the extension busy. Renewals come every ~10 s from
|
|
5
|
+
* an open overlay, but Chrome's intensive throttling slows a page's chained
|
|
6
|
+
* timers to one wake-up a minute once its tab has been hidden 5 minutes, so the
|
|
7
|
+
* lease must outlast a 60 s gap with room to spare (an overlay left open in a
|
|
8
|
+
* background tab must still hold off a reload).
|
|
9
|
+
*/
|
|
10
|
+
export declare const BUSY_LEASE_MS: number;
|
|
11
|
+
export declare const CODE_MANIFEST_FILE = "mac-code.json";
|
|
12
|
+
/** chrome.storage.session: { code, since } — the shipped code first seen verified on disk. */
|
|
13
|
+
export declare const SEEN_KEY = "selfUpdateSeen";
|
|
14
|
+
/** chrome.storage.local: { code, at } — the last reload this extension asked for. */
|
|
15
|
+
export declare const ATTEMPT_KEY = "selfUpdateAttempt";
|
|
16
|
+
/** chrome.storage.local: { code, version } — the code Chrome loaded (written when it loads). */
|
|
17
|
+
export declare const LOADED_KEY = "selfUpdateLoaded";
|
|
18
|
+
/** chrome.storage.session: epoch ms until which the extension is busy (an open overlay renews it). */
|
|
19
|
+
export declare const BUSY_LEASE_KEY = "selfUpdateBusyUntil";
|
|
20
|
+
interface Seen {
|
|
21
|
+
code: string;
|
|
22
|
+
since: number;
|
|
23
|
+
}
|
|
24
|
+
interface Attempt {
|
|
25
|
+
code: string;
|
|
26
|
+
at: number;
|
|
27
|
+
}
|
|
28
|
+
export declare function sha256Hex(data: ArrayBuffer | string): Promise<string>;
|
|
29
|
+
/** The id over a file list; the same algorithm as baselib code-manifest's codeIdOf. */
|
|
30
|
+
export declare function codeIdOf(files: Record<string, string>): Promise<string>;
|
|
31
|
+
/**
|
|
32
|
+
* The code on disk now: the manifest's id when every listed file is present and
|
|
33
|
+
* matches and the id is the id of the list; null otherwise (a merge in
|
|
34
|
+
* progress, a local edit, no manifest). An extension URL reads the folder as
|
|
35
|
+
* it is now (measured 2026-10-31).
|
|
36
|
+
*/
|
|
37
|
+
export declare function diskCode(): Promise<string | null>;
|
|
38
|
+
/**
|
|
39
|
+
* Record the code Chrome just loaded. Call from runtime.onInstalled and
|
|
40
|
+
* runtime.onStartup — the moments Chrome reads the extension from disk — and
|
|
41
|
+
* nowhere else: a worker restarting after eviction runs the code loaded
|
|
42
|
+
* earlier, whatever the disk holds now.
|
|
43
|
+
*/
|
|
44
|
+
export declare function recordLoadedCode(): Promise<string>;
|
|
45
|
+
/** The code this extension runs: the record from its last load (re-taken when the record predates this manifest). */
|
|
46
|
+
export declare function loadedCode(): Promise<string>;
|
|
47
|
+
/** Mark the extension busy for BUSY_LEASE_MS (an open overlay calls this through the worker every ~10 s). */
|
|
48
|
+
export declare function extendBusyLease(now?: number): Promise<void>;
|
|
49
|
+
export declare function busyLeaseActive(now?: number): Promise<boolean>;
|
|
50
|
+
/** Is one of the extension's own pages open in a tab (an options or approval page a reload would close)? */
|
|
51
|
+
export declare function extensionPageOpen(): Promise<boolean>;
|
|
52
|
+
export type UpdateAction = 'none' | 'wait' | 'reload';
|
|
53
|
+
/** The pure decision. `seen` and `attempt` are the stored records (or undefined). */
|
|
54
|
+
export declare function decideUpdate(input: {
|
|
55
|
+
loaded: string;
|
|
56
|
+
disk: string | null;
|
|
57
|
+
shipped: string | null | undefined;
|
|
58
|
+
seen: Seen | undefined;
|
|
59
|
+
attempt: Attempt | undefined;
|
|
60
|
+
now: number;
|
|
61
|
+
}): {
|
|
62
|
+
action: UpdateAction;
|
|
63
|
+
seen: Seen | null;
|
|
64
|
+
};
|
|
65
|
+
/**
|
|
66
|
+
* Check and reload when the rules allow. `shipped`: the code the server last
|
|
67
|
+
* said it deployed (null when unknown). `busy`: the extension's own work in
|
|
68
|
+
* flight; the busy lease and open extension pages are checked here too.
|
|
69
|
+
*/
|
|
70
|
+
export declare function maybeSelfUpdate(options: {
|
|
71
|
+
busy: boolean;
|
|
72
|
+
shipped: string | null | undefined;
|
|
73
|
+
log?: (message: string) => void;
|
|
74
|
+
}): Promise<UpdateAction | 'busy'>;
|
|
75
|
+
export {};
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// Self-update for an unpacked Chrome extension that Steve's Mac loads from a
|
|
2
|
+
// git checkout (plasticine-way mac/sync/DESIGN.md §3.4). Generalised from
|
|
3
|
+
// Reader's extension/self-update.js (1.3.7, 2026-10-31).
|
|
4
|
+
//
|
|
5
|
+
// The Mac job keeps the checkout at origin/main; Chrome reads an unpacked
|
|
6
|
+
// extension's code only when it loads it, so the worker must reload itself.
|
|
7
|
+
// It does so only into code that (1) verifies file by file against the
|
|
8
|
+
// committed `mac-code.json` on disk, (2) is the code the app's server says it
|
|
9
|
+
// deployed (`shipped`, from the server's answer to the extension's report),
|
|
10
|
+
// and (3) differs from the code this worker loaded. Then: only once that code
|
|
11
|
+
// has been on disk STABLE_MS, never while busy, and at most once per code per
|
|
12
|
+
// RETRY_MS so a reload that does not take cannot loop. chrome.runtime.reload()
|
|
13
|
+
// reloads from the folder and keeps chrome.storage; Chrome honours it only
|
|
14
|
+
// with Developer mode on, which Load unpacked needs anyway.
|
|
15
|
+
//
|
|
16
|
+
// Plain browser ESM with no imports: Lurch bundles it; Reader and Snip (no
|
|
17
|
+
// build step) commit a byte-identical copy of the compiled file, checked by a
|
|
18
|
+
// test in each repo.
|
|
19
|
+
export const STABLE_MS = 60 * 1000;
|
|
20
|
+
export const RETRY_MS = 6 * 60 * 60 * 1000;
|
|
21
|
+
/**
|
|
22
|
+
* How long one renewal keeps the extension busy. Renewals come every ~10 s from
|
|
23
|
+
* an open overlay, but Chrome's intensive throttling slows a page's chained
|
|
24
|
+
* timers to one wake-up a minute once its tab has been hidden 5 minutes, so the
|
|
25
|
+
* lease must outlast a 60 s gap with room to spare (an overlay left open in a
|
|
26
|
+
* background tab must still hold off a reload).
|
|
27
|
+
*/
|
|
28
|
+
export const BUSY_LEASE_MS = 150 * 1000;
|
|
29
|
+
export const CODE_MANIFEST_FILE = 'mac-code.json';
|
|
30
|
+
/** chrome.storage.session: { code, since } — the shipped code first seen verified on disk. */
|
|
31
|
+
export const SEEN_KEY = 'selfUpdateSeen';
|
|
32
|
+
/** chrome.storage.local: { code, at } — the last reload this extension asked for. */
|
|
33
|
+
export const ATTEMPT_KEY = 'selfUpdateAttempt';
|
|
34
|
+
/** chrome.storage.local: { code, version } — the code Chrome loaded (written when it loads). */
|
|
35
|
+
export const LOADED_KEY = 'selfUpdateLoaded';
|
|
36
|
+
/** chrome.storage.session: epoch ms until which the extension is busy (an open overlay renews it). */
|
|
37
|
+
export const BUSY_LEASE_KEY = 'selfUpdateBusyUntil';
|
|
38
|
+
function hex(bytes) {
|
|
39
|
+
return Array.from(new Uint8Array(bytes), (b) => b.toString(16).padStart(2, '0')).join('');
|
|
40
|
+
}
|
|
41
|
+
export async function sha256Hex(data) {
|
|
42
|
+
const bytes = typeof data === 'string' ? new TextEncoder().encode(data) : data;
|
|
43
|
+
return hex(await crypto.subtle.digest('SHA-256', bytes));
|
|
44
|
+
}
|
|
45
|
+
/** The id over a file list; the same algorithm as baselib code-manifest's codeIdOf. */
|
|
46
|
+
export async function codeIdOf(files) {
|
|
47
|
+
const lines = Object.keys(files)
|
|
48
|
+
.sort()
|
|
49
|
+
.map((path) => `${path}\0${files[path]}\n`)
|
|
50
|
+
.join('');
|
|
51
|
+
return (await sha256Hex(lines)).slice(0, 16);
|
|
52
|
+
}
|
|
53
|
+
async function readFile(path) {
|
|
54
|
+
try {
|
|
55
|
+
const res = await fetch(chrome.runtime.getURL(path), { cache: 'no-store' });
|
|
56
|
+
if (!res.ok)
|
|
57
|
+
return null;
|
|
58
|
+
return await res.arrayBuffer();
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* The code on disk now: the manifest's id when every listed file is present and
|
|
66
|
+
* matches and the id is the id of the list; null otherwise (a merge in
|
|
67
|
+
* progress, a local edit, no manifest). An extension URL reads the folder as
|
|
68
|
+
* it is now (measured 2026-10-31).
|
|
69
|
+
*/
|
|
70
|
+
export async function diskCode() {
|
|
71
|
+
const raw = await readFile(CODE_MANIFEST_FILE);
|
|
72
|
+
if (!raw)
|
|
73
|
+
return null;
|
|
74
|
+
let manifest;
|
|
75
|
+
try {
|
|
76
|
+
manifest = JSON.parse(new TextDecoder().decode(raw));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
const { id, files } = manifest;
|
|
82
|
+
if (typeof id !== 'string' || !files || typeof files !== 'object')
|
|
83
|
+
return null;
|
|
84
|
+
const list = files;
|
|
85
|
+
for (const value of Object.values(list))
|
|
86
|
+
if (typeof value !== 'string')
|
|
87
|
+
return null;
|
|
88
|
+
if ((await codeIdOf(list)) !== id)
|
|
89
|
+
return null;
|
|
90
|
+
for (const [path, expected] of Object.entries(list)) {
|
|
91
|
+
const bytes = await readFile(path);
|
|
92
|
+
if (!bytes || (await sha256Hex(bytes)) !== expected)
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
return id;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Record the code Chrome just loaded. Call from runtime.onInstalled and
|
|
99
|
+
* runtime.onStartup — the moments Chrome reads the extension from disk — and
|
|
100
|
+
* nowhere else: a worker restarting after eviction runs the code loaded
|
|
101
|
+
* earlier, whatever the disk holds now.
|
|
102
|
+
*/
|
|
103
|
+
export async function recordLoadedCode() {
|
|
104
|
+
const code = (await diskCode()) ?? 'unverified';
|
|
105
|
+
const record = { code, version: chrome.runtime.getManifest().version };
|
|
106
|
+
await chrome.storage.local.set({ [LOADED_KEY]: record });
|
|
107
|
+
return code;
|
|
108
|
+
}
|
|
109
|
+
/** The code this extension runs: the record from its last load (re-taken when the record predates this manifest). */
|
|
110
|
+
export async function loadedCode() {
|
|
111
|
+
const { [LOADED_KEY]: stored } = await chrome.storage.local.get(LOADED_KEY);
|
|
112
|
+
const record = stored;
|
|
113
|
+
if (record && typeof record.code === 'string' && record.version === chrome.runtime.getManifest().version)
|
|
114
|
+
return record.code;
|
|
115
|
+
return recordLoadedCode();
|
|
116
|
+
}
|
|
117
|
+
/** Mark the extension busy for BUSY_LEASE_MS (an open overlay calls this through the worker every ~10 s). */
|
|
118
|
+
export async function extendBusyLease(now = Date.now()) {
|
|
119
|
+
await chrome.storage.session.set({ [BUSY_LEASE_KEY]: now + BUSY_LEASE_MS });
|
|
120
|
+
}
|
|
121
|
+
export async function busyLeaseActive(now = Date.now()) {
|
|
122
|
+
const { [BUSY_LEASE_KEY]: until } = await chrome.storage.session.get(BUSY_LEASE_KEY);
|
|
123
|
+
return typeof until === 'number' && until > now;
|
|
124
|
+
}
|
|
125
|
+
/** Is one of the extension's own pages open in a tab (an options or approval page a reload would close)? */
|
|
126
|
+
export async function extensionPageOpen() {
|
|
127
|
+
if (!chrome.runtime.getContexts)
|
|
128
|
+
return false;
|
|
129
|
+
try {
|
|
130
|
+
return (await chrome.runtime.getContexts({ contextTypes: ['TAB'] })).length > 0;
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/** The pure decision. `seen` and `attempt` are the stored records (or undefined). */
|
|
137
|
+
export function decideUpdate(input) {
|
|
138
|
+
const { loaded, disk, shipped, seen, attempt, now } = input;
|
|
139
|
+
if (!shipped || !disk || disk !== shipped || disk === loaded)
|
|
140
|
+
return { action: 'none', seen: null };
|
|
141
|
+
const since = seen && seen.code === disk ? seen.since : now;
|
|
142
|
+
const record = { code: disk, since };
|
|
143
|
+
if (now - since < STABLE_MS)
|
|
144
|
+
return { action: 'wait', seen: record };
|
|
145
|
+
if (attempt && attempt.code === disk && now - attempt.at < RETRY_MS)
|
|
146
|
+
return { action: 'none', seen: record };
|
|
147
|
+
return { action: 'reload', seen: record };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Check and reload when the rules allow. `shipped`: the code the server last
|
|
151
|
+
* said it deployed (null when unknown). `busy`: the extension's own work in
|
|
152
|
+
* flight; the busy lease and open extension pages are checked here too.
|
|
153
|
+
*/
|
|
154
|
+
export async function maybeSelfUpdate(options) {
|
|
155
|
+
const loaded = await loadedCode();
|
|
156
|
+
const disk = await diskCode();
|
|
157
|
+
const { [SEEN_KEY]: seen } = await chrome.storage.session.get(SEEN_KEY);
|
|
158
|
+
const { [ATTEMPT_KEY]: attempt } = await chrome.storage.local.get(ATTEMPT_KEY);
|
|
159
|
+
const now = Date.now();
|
|
160
|
+
const decision = decideUpdate({ loaded, disk, shipped: options.shipped, seen: seen, attempt: attempt, now });
|
|
161
|
+
const prior = seen;
|
|
162
|
+
if (decision.seen) {
|
|
163
|
+
if (!prior || prior.code !== decision.seen.code)
|
|
164
|
+
await chrome.storage.session.set({ [SEEN_KEY]: decision.seen });
|
|
165
|
+
}
|
|
166
|
+
else if (prior) {
|
|
167
|
+
await chrome.storage.session.remove(SEEN_KEY);
|
|
168
|
+
}
|
|
169
|
+
if (decision.action !== 'reload' || !decision.seen)
|
|
170
|
+
return decision.action;
|
|
171
|
+
const target = decision.seen.code;
|
|
172
|
+
if (options.busy || (await busyLeaseActive(now)) || (await extensionPageOpen()))
|
|
173
|
+
return 'busy';
|
|
174
|
+
options.log?.(`code ${target} is deployed and on disk (running ${loaded}): reloading.`);
|
|
175
|
+
await chrome.storage.local.set({ [ATTEMPT_KEY]: { code: target, at: now } });
|
|
176
|
+
chrome.runtime.reload();
|
|
177
|
+
return 'reload';
|
|
178
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@steve31415/baselib",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.48.1",
|
|
4
4
|
"description": "Plasticine new-world shared platform library: logging, auth, service-to-service auth, db, HTTP, sync, app updates",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -88,6 +88,18 @@
|
|
|
88
88
|
"./connector": {
|
|
89
89
|
"types": "./dist/connector.d.ts",
|
|
90
90
|
"default": "./dist/connector.js"
|
|
91
|
+
},
|
|
92
|
+
"./code-manifest": {
|
|
93
|
+
"types": "./dist/code-manifest.d.ts",
|
|
94
|
+
"default": "./dist/code-manifest.js"
|
|
95
|
+
},
|
|
96
|
+
"./client-version": {
|
|
97
|
+
"types": "./dist/client-version.d.ts",
|
|
98
|
+
"default": "./dist/client-version.js"
|
|
99
|
+
},
|
|
100
|
+
"./extension-self-update": {
|
|
101
|
+
"types": "./dist/extension-self-update.d.ts",
|
|
102
|
+
"default": "./dist/extension-self-update.js"
|
|
91
103
|
}
|
|
92
104
|
},
|
|
93
105
|
"bin": {
|
|
@@ -95,7 +107,8 @@
|
|
|
95
107
|
"pw-deploy": "./dist/bin/pw-deploy.js",
|
|
96
108
|
"pw-logs": "./dist/bin/pw-logs.js",
|
|
97
109
|
"pw-rollback": "./dist/bin/pw-rollback.js",
|
|
98
|
-
"pw-sql": "./dist/bin/pw-sql.js"
|
|
110
|
+
"pw-sql": "./dist/bin/pw-sql.js",
|
|
111
|
+
"pw-code-manifest": "./dist/bin/pw-code-manifest.js"
|
|
99
112
|
},
|
|
100
113
|
"engines": {
|
|
101
114
|
"node": "22.x"
|